We use nmap to discover open ports and services. Initially we use only the -sV
option along with the -p-
option to check for all ports but not make the scan too long by adding additional options.
nmap -sV -p- 10.10.11.55
-sV
: Detect the service and version which is running.-p-
: Run the scan on all portsPress enter or click to view image in full size
Now that we know that only 2 ports are open, we use the -p
option for the 2 ports and the -sC
option.
nmap -p22,80 -sV -sC 10.10.11.55
-p22,80
: Run the scan on only these 2 ports.-sC
: Run a default set of NSE scripts to get information about the open services.Also we see the host name is titanic.htb
which we have to add to our /etc/hosts
file because of virtual hosting set up.
Press enter or click to view image in full size
Currently we cannot access the IP directly because of virtual hosting set up on the server. We need the domain to be mapped to the IP in the /etc/hosts
file so that the host header is set as titanic.htb
when we make requests to the web server.
Press enter or click to view image in full size
Port 80 provides only a form which takes in some data and allows us to download our input back as a JSON file once we submit the form.
Press enter or click to view image in full size
Running gobuster
to search for directories did not reveal much other than the API paths which are being called when we submit the form.
gobuster dir -u http://titanic.htb -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
dir
: Uses directory/file enumeration mode-u
: The target URL-w
: The wordlist to usePress enter or click to view image in full size
We start looking at the API’s in burp. The URL for downloading our data is http://titanic.htb/download?ticket=<RANDOM_ID>.json
and this lets us download the file to our local machine as you can see in the attachment part in the Content-Disposition
response header.
Press enter or click to view image in full size
Since the ticket
parameter in the GET request refers to a file in the filesystem, we can try to check if there is LFI.
We try to access /etc/passwd
file and we see that we do get the file as our output. In the output we see the user developer
in the file as well.
Maybe we can use this as a username later on ? 🤔
Press enter or click to view image in full size
Since we have LFI, we can also try getting the user’s flag from the developer’s home directory.
Press enter or click to view image in full size
user.txt -> c3d59***************************
Great, now lets see if we can try to escalate this and get a shell or RCE in some form 😊.
Spoiler alert, the only thing I escalated was my own expectations for getting a shell . No RCE for me yet 😞.
Some of the things which I tried but did not work.
id_rsa
in home/developer/.ssh/id_rsa
which we could use to SSH inside.Maybe we missed something else during our enumeration phase. We go back and search for subdomains using the gobuster vhost
command and we get a hit: dev.titanic.htb
gobuster vhost -u http://titanic.htb -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-5000.txt --append-domain -t 100 --exclude-length 300-325
vhost
: Uses VHOST enumeration mode-u
: The target URL-w
: Path to the wordlist--append-domain
: Append main domain from URL to words from wordlist. Eg: append titanic.htb
to dev
-t
: Number of concurrent threads--exclude-length
: Exclude the following content lengths. We are using this as there are many invalid responses which just redirect back to titanic.htb.Press enter or click to view image in full size
We add this subdomain to our hosts file as well and now we can access it.
We open dev.titanic.htb
and we see that it is running Gitea
.
Gitea is a self-hostable web service for managing Git repositories. The version
1.22.1
is shown at the bottom of the page.
Press enter or click to view image in full size
We look at the repositories present and we see two of them under the developer user.
flask-app
— This refers to the flask-app source code running on titanic.htb.docker-config
— This hosts some docker-compose.yaml files of gitea
and mysql
.Press enter or click to view image in full size
We look at the source code for the flask app but do not find anything special there to go forward on.
(See the POST ROOT BONUS 5.1 section at the end to see how the LFI issue is introduced in the flask-app 😉)
Next, we see the docker-config
repository and this has some interesting information.
docker-compose.yaml
has some credentials which we could use later on. We take note of these 📝.Press enter or click to view image in full size
2. The gitea docker-compose.yaml
has its volumes mapped to the developer’s home directory. We can use the LFI to read data present here.
Press enter or click to view image in full size
Lets try enumerating more information from this directory with the LFI. The below URL can be used to get the gitea configuration values with LFI./download?ticket=../../../home/developer/gitea/data/gitea/conf/app.ini
Press enter or click to view image in full size
Q) How did I know about the app.ini
configuration file?
A) I ran gitea’s docker image locally and exec’d into the container to see the folder structure inside.
Press enter or click to view image in full size
In the app.ini
, we see a lot of configuration values but mainly we find the location of gitea’s sqlite database.
[database]
PATH = /data/gitea/gitea.db
DB_TYPE = sqlite3
HOST = localhost:3306
NAME = gitea
USER = root
PASSWD =
LOG_SQL = false
SCHEMA =
SSL_MODE = disable
Now we can pull this gitea.db
to our local machine by going to http://titanic.htb/download?ticket=../../../../../../../home/developer/gitea/data/gitea/gitea.db
We open the gitea.db
file using sqlite3
and we see that there is table called user
which we can enumerate on. The table contains the password hashes, salts and the hashing algorithm used.
Press enter or click to view image in full size
Press enter or click to view image in full size
The hashing algorithm used is pbkdf2$50000$50
. Some information about this algorithm is given here. Lets try to crack these hashes using hashcat.
Iterations
— 50000Key length
— 50This page gives us the format hashcat needs the hash to be so that it can crack it. The hashing algos could be sha1,sha256 or sha512. We can test it one by one.
Example format: sha1:[iteration count]:[salt in base64]:[digest in base64]
We can convert this to the base64 format using: echo “<HASH/SALT>” | xxd -r -p | base64
Press enter or click to view image in full size
We put these hashes inside a file and after testing out the different SHA algorithms, we cracked the password for the developer
user using:hashcat -m 10900 -a 0 developer.txt /usr/share/wordlists/rockyou.txt
-m
10900 -> PBKDF2-HMAC-SHA256-a 0
-> Attack mode (Dictionary attack)developer.txt
-> sha256:50000:[salt in base64]:[digest in base64]Password cracked -> 25282528
Nice, we can use this to SSH in as the developer user 🥳.
Now, lets try to escalate our privileges to root.
sudo -l
did not give anything.cat /etc/crontab
command.We run linpeas
to find any privilege escalation vectors if any.
Linpeas
output:
/home/developer/.local/bin
is the first entry in the PATH variable and we can write to it./opt/app/app.py
is where the flask app is being run. /opt/app/static/assets/images/metadata.log
We have a few interesting points to go ahead with. Firstly, we get to the /opt
directory and we see a app
folder(for running the flask-app), containerd
folder and a scripts
folder as well. We cannot open the containerd folder because of the permissions.
Press enter or click to view image in full size
Inside the scripts
folder, we see the identify_images.sh script. The content is given below.
cd /opt/app/static/assets/images
truncate -s 0 metadata.log
find /opt/app/static/assets/images/ -type f -name "*.jpg" | xargs /usr/bin/magick identify >> metadata.log
The script does the below actions:
/opt/app/static/assets/images
directory.metadata.log
file using the truncate command.magick
command.metadata.log
.This script seems to run as root periodically every couple of minutes because the last modified time for metadata.log
also keeps updating every few minutes. I was unable to find a crontab for this as my current user tho. (See the POST ROOT BONUS 5.3 section to find out where is the crontab set 😉).
Press enter or click to view image in full size
Great, now that we know that the script is being run as root, we can try to create a fake truncate
command inside the /home/developer/local/bin
directory and that would run when the script runs as it is the first entry in the $PATH environment variable.
Welp, that did not end up working but you can find out about why in the POST ROOT BONUS 5.2 section 😉.
Right so we move on to the next approach. Since we know that PATH based binaries were not working, we enumerate the other binary which is in the script i.e ImageMagick.
Press enter or click to view image in full size
Googling about magick exploits 7.1.1–35
leads us to CVE-2024–41817. A overview of the exploit is given here.
Brief overview of the exploit — While ImageMagick
is executing, it might use the current working directory as the path to search for the configuration file(delegates.xml
) or shared libraries(.so
). So if an attacker can control the file in the current working directory while ImageMagick
is executing, it might lead to arbitrary code execution by loading a malicious configuration file or shared library.
Since we can write files inside the /opt/app/static/assets/images/
directory, how about we put a shared object file there ?
Lets try it out. We compile the below C code into shared library and wait for magick
to pick it up in a couple of minutes.
Press enter or click to view image in full size
-x c
: Treat the input as C source code-shared
: Create a shared object file (.so
)-fPIC
: Generate position-independent code, which is required for shared libraries.-o
: Output file nameWe set up a listener on our machine and we get a callback 🥳.
root.txt -> 34efe***************************
Press enter or click to view image in full size
from flask import Flask, request, jsonify, send_file, render_template, redirect, url_for, Response
import os
import json
from uuid import uuid4app = Flask(__name__)
TICKETS_DIR = "tickets"
if not os.path.exists(TICKETS_DIR):
os.makedirs(TICKETS_DIR)
@app.route('/')
def index():
return render_template('index.html')
@app.route('/book', methods=['POST'])
def book_ticket():
data = {
"name": request.form['name'],
"email": request.form['email'],
"phone": request.form['phone'],
"date": request.form['date'],
"cabin": request.form['cabin']
}
ticket_id = str(uuid4())
json_filename = f"{ticket_id}.json"
json_filepath = os.path.join(TICKETS_DIR, json_filename)
with open(json_filepath, 'w') as json_file:
json.dump(data, json_file)
return redirect(url_for('download_ticket', ticket=json_filename))
@app.route('/download', methods=['GET'])
def download_ticket():
ticket = request.args.get('ticket')
if not ticket:
return jsonify({"error": "Ticket parameter is required"}), 400
json_filepath = os.path.join(TICKETS_DIR, ticket)
if os.path.exists(json_filepath):
return send_file(json_filepath, as_attachment=True, download_name=ticket)
else:
return jsonify({"error": "Ticket not found"}), 404
if __name__ == '__main__':
app.run(host='127.0.0.1', port=5000)
The issue arises in the /download
API which takes in the ticket parameter and does no input sanitization on it. Next, the value in ticket is appended to the string value tickets
.
TICKETS_DIR = "tickets"
ticket = request.args.get('ticket')
json_filepath = os.path.join(TICKETS_DIR, ticket)
If the user input is ../../../../etc/passwd
, the json_file
output becomes tickets/../../../../etc/passwd
-> /etc/passwd
. This is how we exploited the LFI in this box.
We created a fake truncate
command which would give us a shell in the /home/developer/.local/bin
and setup a listener on our machine.
Press enter or click to view image in full size
We waited for some time but did not receive a shell. Tried different payloads but it did not work so was kinda scratching my head. Found the reason once I became root.
As you know, the script identify_images.sh
is being run as root but it is not necessary for the root user’s $PATH
to be the same as the developer’s $PATH
. The root user’s $PATH
did not have the /home/developer/.local/bin
entry as shown below. Hence, the script when run did not pick up the truncate
command which we had created.
Press enter or click to view image in full size
The reason the developer user had that entry in his path is because of the /home/developer/.profile
which set this specifically for this user.
Press enter or click to view image in full size
identify_images.sh
script ?Once we become root, we can run crontab -l
for the root user to see if there are any cronjobs.
Press enter or click to view image in full size
Press enter or click to view image in full size
We see that identify_images.sh
is being run every minute and a cleanup of the shared library also takes place using cleanup.sh
.
Every 10 minutes, the /opt/app/static/assets/images
directory is being reset to a predefined directory using the revert.sh
script.
Press enter or click to view image in full size