
Preuve de concept en C pour CVE-2026-8933, une élévation de privilèges locale dans snap-confine exploitant le gel des cgroups, des courses de liens symboliques et l'injection de règles udev pour exécuter des commandes en tant que root.
Reproduire l'avis Qualys « Local Privilege Escalation in set-capabilities versions of snap-confine » (https://cdn2.qualys.com/advisory/2026/07/21/snap-confine-set-capabilities.txt).
Testé sur la machine locale (Ubuntu 25.10 / snapd 2.73+ubuntu25.10.1 / noyau 6.17) : en partant de l'uid 1000,
la commande est finalement exécutée en root par systemd-udevd, et /tmp/pwned obtient
uid=0(root) gid=0(root) groups=0(root), conformément à l'effet démontré à la fin de l'avis.
Les notes sur les pièges rencontrés lors de la reproduction, ainsi que l'explication de pourquoi la première évaluation a conclu à tort « non exploitable », sont dans NOTES.md.
| Fichier | Description |
|---|---|
poc.c | PoC complet (fichier unique) |
Makefile | Compilation (liaison avec le libfuse3.so.3.17.4 installé sur le système) |
include/ | En-têtes de libfuse3 3.17.4 (correspondant à la bibliothèque d'exécution du système) |
NOTES.md | Notes sur les pièges rencontrés + rétrospective sur la méthodologie d'analyse d'accessibilité |
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)
Les numéros de ligne proviennent du code source de snapd 2.73 de cette machine (cmd/snap-confine/mount-support.c). L'avis Qualys
cite sa propre copie du fichier, dont les numéros de ligne correspondants sont 511 / 523 / 530 / 535 / 536 /
362 / 450 / 454 — les instructions correspondent une à une, seuls les numéros de ligne diffèrent.
Sur cette machine, /usr/lib/snapd/snap-confine est en mode=755 + capacités de fichier (et non
setuid-root), il s'exécute donc avec l'euid de l'attaquant tout en détenant des capacités proches de root : les répertoires et
fichiers qu'il crée appartiennent à l'attaquant pendant un court instant, et l'open() de la ligne 418 suit les liens symboliques.
La désassemblage confirme : sur cette machine, les indicateurs de open@plt sont 0x240 (O_CREAT|O_TRUNC, sans
O_NOFOLLOW) ; au même point dans la version corrigée 2.77.1, ils sont 0x20240.
systemd-run --user --scope --unit=snap.<snap>.<app>. Ce scope appartient à l'utilisateur courant, cgroup.freeze est accessible en écriture,
ce qui permet de laisser passer snap-confine appel système par appel système.SNAPD_DEBUG=1, snap-confine affiche
associated snap application process ... with device cgroup. Cette ligne apparaît après l'attachement du programme BPF
du cgroup de périphériques, avant mkdtemp(). Il ne faut pas geler plus tôt :
le vérificateur BPF renvoie EAGAIN en présence d'un freeze trap, et snap-confine quitte immédiatement.
stderr est connecté à un pty (pas un pipe) : sinon stdio serait en tampon de bloc et le marqueur ne serait souvent
vidé qu'après mkdtemp() et le bind mount.
De plus, le scope démarre avec CPUQuota=1% : entre le marqueur et mkdtemp(), il n'y a que quelques centaines de microsecondes de
temps CPU, du même ordre de grandeur que la latence de réveil du processus de capture ; la limitation étire cette fenêtre en temps réel jusqu'à
l'ordre de la milliseconde, et une fois le gel confirmé, cpu.max est immédiatement restauré à 100%.mount(2) suit les liens symboliques dans le chemin
cible, de sorte que les bind/unbindable/tmpfs de snap-confine atterrissent tous sur
<scratch>.dA, tandis que la boucle de copie utilise toujours la chaîne du répertoire scratch.ns_mount_count(sc, .dA) >= 2), rediriger le lien symbolique vers
<scratch>.dB, et placer dans dB des liens symboliques portant le même nom que les fichiers ordinaires de premier niveau du rootfs de base,
pointant vers /run/udev/rules.d/97-cve-2026-8933-<pid>-<rand>.rules. À ce moment, la résolution de chemin de la boucle de copie
aboutit à dB — un répertoire ordinaire que nous contrôlons entièrement. Le tmpfs reste sur dA et ne nous
gêne plus.
Attention : avec un seul montage sur .dA, il s'agit d'un bind mount (/tmp est lui-même un tmpfs, et après bind
le fstype reste tmpfs) ; il faut attendre le 2e montage (le vrai tmpfs) pour rediriger, sinon
le mount --make-unbindable de snap-confine échouerait avec EINVAL parce que le chemin n'est plus la racine d'un montage.open(O_CREAT|O_TRUNC) (suit le lien symbolique, crée le fichier sous /run/udev/rules.d en tant que root),
puis fchown(fd,0,0). À cet instant, le fichier nous appartient encore. Le PoC utilise simultanément
deux moyens :
stat() voit le fichier
et que l'uid est encore le nôtre, immédiatement chmod 0666 + écrire la règle udev (snap-confine étant alors gelé, la fenêtre est « élargie ») ;chmod(). Une fois que le spinner
l'emporte, le fichier appartient à root mais son mode est 0666 — les bits de mode sont conservés après
fchown(0,0), donc le chemin principal peut également finir de l'écrire (le code teste « peut-on écrire », pas « est-on
propriétaire »)./run/udev/** rw.
Monter/démonter un système de fichiers FUSE amène le noyau à émettre un uevent add pour le sous-système bdi,
et systemd-udevd exécute alors le PROGRAM= de la règle en tant que root.make
./poc --diagnose # 只读自检,不改动任何系统状态
./poc # exploit,默认最多 5 次尝试
./poc --attempts 8 # 增加尝试次数
./poc --file default256.png # 只用 base rootfs 里的这一个普通文件
sudo ./poc --cleanup # 清理(仅在异常中断留下残留时需要)
Paramètres :
| Paramètre | Description |
|---|---|
--snap NAME | Quel snap utiliser pour le rootfs (par défaut firefox, également disponible via la variable d'environnement CVE8933_SNAP) |
--app NAME | L'app de ce snap (par défaut, le premier sous apps: dans meta/snap.yaml) |
--file NAME | N'utiliser que ce fichier ordinaire dans le rootfs de base ; erreur et sortie si aucune correspondance |
--attempts N | Nombre de tentatives (par défaut 5) |
--step US / --step2 US | Temps CPU par tranche pour les deux phases (microsecondes), pour le débogage |
--keep | Conserver les répertoires <scratch>.dA / .dB et les liens symboliques (pour le débogage) |
--diagnose | Auto-vérification en lecture seule |
--cleanup | Nettoyer les résidus historiques (nécessite root) |
Les journaux sont écrits dans /tmp/cve8933.log, la sortie de débogage de snap-confine dans
/tmp/cve8933-debug.txt.
CVE8933_PAYLOAD_CMD='<commande shell>' permet de remplacer le corps du payload (pour démontrer l'exécution d'autres
commandes root), auquel cas le nettoyage automatique n'a pas lieu ; la commande personnalisée doit toujours écrire dans /tmp/pwned, sinon le PoC
considérera que cette tentative a échoué.