
Proof-of-concept in C per CVE-2026-8933, un'escalation di privilegi locale in snap-confine che sfrutta il congelamento dei cgroup, race condition su symlink e iniezione di regole udev per eseguire comandi come root.
Riprodurre l'avviso di Qualys «Local Privilege Escalation in set-capabilities versions of snap-confine» (https://cdn2.qualys.com/advisory/2026/07/21/snap-confine-set-capabilities.txt).
Testato su questa macchina (Ubuntu 25.10 / snapd 2.73+ubuntu25.10.1 / kernel 6.17): partendo da uid 1000,
infine systemd-udevd esegue il comando come root, /tmp/pwned ottiene
uid=0(root) gid=0(root) groups=0(root), coerente con l'effetto dimostrato alla fine dell'avviso.
Le note sugli ostacoli incontrati durante la riproduzione, e perché la prima valutazione aveva erroneamente giudicato "non sfruttabile", sono in NOTES.md.
| File | Descrizione |
|---|---|
poc.c | PoC completo (file singolo) |
Makefile | Build (collega la libfuse3.so.3.17.4 già installata nel sistema) |
include/ | Header di libfuse3 3.17.4 (corrispondenti alla libreria runtime di sistema) |
NOTES.md | Note sugli ostacoli + revisione metodologica dell'analisi di raggiungibilità |
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)
I numeri di riga sono presi dal codice sorgente di snapd 2.73 presente su questa macchina (cmd/snap-confine/mount-support.c). L'avviso di Qualys
fa riferimento alla propria copia del file, con i numeri di riga corrispondenti
511 / 523 / 530 / 535 / 536 /
362 / 450 / 454 — le istruzioni corrispondono una a una, solo i numeri di riga sono diversi.
Su questa macchina /usr/lib/snapd/snap-confine è mode=755 + file capabilities (non
setuid-root), quindi viene eseguito con l'euid dell'attaccante ma possiede capacità quasi
root: le directory e i file che crea appartengono all'attaccante per un breve periodo,
e l'open() alla riga 418 segue i collegamenti simbolici.
Il disassemblaggio conferma: i flag di open@plt su questa macchina sono 0x240 (O_CREAT|O_TRUNC, senza
O_NOFOLLOW); nello stesso punto della versione corretta 2.77.1 sono 0x20240.
systemd-run --user --scope --unit=snap.<snap>.<app>. Questo scope appartiene all'utente corrente, cgroup.freeze è scrivibile,
quindi è possibile lasciar procedere snap-confine una system call alla volta.SNAPD_DEBUG=1 snap-confine stampa
associated snap application process ... with device cgroup. Questa riga compare dopo l'attach del programma BPF del
cgroup dei dispositivi e prima di mkdtemp(). Non si può congelare prima: il
verificatore BPF restituisce EAGAIN in presenza di un freeze trap, e snap-confine esce direttamente.
stderr è collegato a un pty (non a una pipe): altrimenti stdio sarebbe bufferizzato a blocchi e il marker
verrebbe spesso emesso solo dopo mkdtemp() e i bind mount.
Inoltre lo scope viene avviato con CPUQuota=1%: dal marker a mkdtemp() ci sono solo poche centinaia di microsecondi di
tempo CPU, dello stesso ordine di grandezza della latenza di risveglio del processo che effettua la cattura; la limitazione allunga questa finestra in tempo reale
fino all'ordine dei millisecondi, e subito dopo la conferma del congelamento si ripristina cpu.max al 100%.mount(2) segue i symlink nel percorso
di destinazione, quindi il bind/unbindable/tmpfs di snap-confine finisce tutto su
<scratch>.dA, mentre il ciclo di copia continua a usare la stringa della directory scratch.ns_mount_count(sc, .dA) >= 2), far puntare di nuovo il symlink a
<scratch>.dB, e inserire in dB symlink con lo stesso nome dei file regolari di primo livello del rootfs base,
che puntano a /run/udev/rules.d/97-cve-2026-8933-<pid>-<rand>.rules. A questo punto la risoluzione del percorso del ciclo di copia
ricade su dB — una directory normale che controlliamo completamente. Il tmpfs resta in dA e non ci
ostacola più.
Nota: quando su .dA c'è un solo mount si tratta di un bind mount (/tmp è già tmpfs, dopo il bind
il fstype resta tmpfs), bisogna attendere il secondo mount (il vero tmpfs) prima di cambiare destinazione, altrimenti
il mount --make-unbindable di snap-confine fallisce con EINVAL perché il percorso non è più la radice di un mount.open(O_CREAT|O_TRUNC) (seguendo il symlink, creando il file come root sotto /run/udev/rules.d),
e subito dopo fchown(fd,0,0). In questo istante il file appartiene ancora a noi. Il PoC usa contemporaneamente
due mezzi:
stat() vede il file
e l'uid è ancora il nostro, eseguire subito chmod 0666 + scrivere la regola udev (in quel momento snap-confine
è congelato e la finestra viene "allargata");chmod() si contendono la stessa finestra. Quando lo spinner la conquista,
il file è di proprietà di root ma con modo 0666 — i bit di modo restano dopo fchown(0,0), quindi anche il percorso principale
riesce a completarne la scrittura (il codice verifica "se si può scrivere", non "se si è il proprietario")./run/udev/** rw.
Montare/smontare un filesystem FUSE fa sì che il kernel emetta un uevent add per il sottosistema bdi,
e systemd-udevd esegue quindi come root il PROGRAM= della regola.make
./poc --diagnose # 只读自检,不改动任何系统状态
./poc # exploit,默认最多 5 次尝试
./poc --attempts 8 # 增加尝试次数
./poc --file default256.png # 只用 base rootfs 里的这一个普通文件
sudo ./poc --cleanup # 清理(仅在异常中断留下残留时需要)
Parametri:
| Parametro | Descrizione |
|---|---|
--snap NAME | Quale rootfs dello snap usare (predefinito firefox, oppure la variabile d'ambiente CVE8933_SNAP) |
--app NAME | L'app di quello snap (predefinito: il primo sotto apps: in meta/snap.yaml) |
--file NAME | Usa solo questo singolo file regolare dal rootfs di base; esce con errore se non c'è corrispondenza |
--attempts N | Numero di tentativi (predefinito 5) |
--step US / --step2 US | Tempo CPU per slice delle due fasi (microsecondi), per il debug |
--keep | Conserva le directory <scratch>.dA / .dB e i symlink (per il debug) |
--diagnose | Autodiagnostica in sola lettura |
--cleanup | Pulisce i residui storici (richiede root) |
I log vengono scritti in /tmp/cve8933.log, l'output di debug di snap-confine viene scritto in
/tmp/cve8933-debug.txt.
CVE8933_PAYLOAD_CMD='<comando shell>' può sostituire il corpo del payload (per dimostrare l'esecuzione di altri
comandi root), in questo caso non viene eseguita la pulizia automatica; il comando personalizzato deve comunque scrivere /tmp/pwned, altrimenti il PoC
considererà il tentativo non riuscito.
$ ./poc
[+] WIN: uid=0(root) gid=0(root) groups=0(root)
[+] exploit succeeded, evidence in /tmp/pwned
/tmp/cve8933.log corrisponde a un processo riuscito (2026-10-23, retest dopo le modifiche):