
Prueba de concepto en C para CVE-2026-8933, una escalada de privilegios local en snap-confine que utiliza el congelamiento de cgroups, condiciones de carrera con enlaces simbólicos e inyección de reglas udev para ejecutar comandos como root.
Reproducir el aviso de 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)
Los números de línea provienen del código fuente de snapd 2.73 de esta máquina (cmd/snap-confine/mount-support.c). El aviso de Qualys
cita su propia copia del archivo, con los números de línea correspondientes 511 / 523 / 530 / 535 / 536 /
362 / 450 / 454 — las sentencias se corresponden una a una, solo difieren los números de línea.
El archivo local /usr/lib/snapd/snap-confine tiene mode=755 + file capabilities (no
es setuid-root), por lo que se ejecuta con el euid del atacante pero posee capacidades
casi de root: los directorios y archivos que crea pertenecen al atacante durante un breve
periodo de tiempo, y el open() de la línea 418 sigue enlaces simbólicos.
La desensamblación confirma: en esta máquina los flags de open@plt son 0x240 (O_CREAT|O_TRUNC, sin
O_NOFOLLOW); en el mismo punto de la versión 2.77.1 ya corregida son 0x20240.
systemd-run --user --scope --unit=snap.<snap>.<app>. Ese scope pertenece al usuario actual, cgroup.freeze es escribible,
así que se puede dejar pasar a snap-confine llamada a sistema por llamada a sistema.SNAPD_DEBUG=1, snap-confine imprime
associated snap application process ... with device cgroup. Esa línea aparece después de adjuntar el programa BPF del
cgroup de dispositivos y antes de mkdtemp(). No se puede congelar antes: el verificador BPF devuelve EAGAIN cuando existe una freeze trap,
y snap-confine termina directamente.
stderr está conectado a un pty (no a una tubería): de lo contrario stdio se almacena en búfer de bloque y el marcador suele
vaciarse solo después de mkdtemp() y del bind mount.
Además, el scope se inicia con CPUQuota=1%: desde el marcador hasta mkdtemp() solo hay unos cientos de microsegundos de
tiempo de CPU, del mismo orden que la latencia de despertar del proceso capturador; la limitación estira esa ventana en tiempo real hasta
el orden de los milisegundos, y tras confirmar la congelación se restaura cpu.max a 100% de inmediato.mount(2) sigue los enlaces simbólicos en la ruta
de destino, así que el bind/unbindable/tmpfs de snap-confine acaba todo en
<scratch>.dA, mientras que el bucle de copia sigue usando la cadena del directorio scratch.ns_mount_count(sc, .dA) >= 2), redirigir el enlace simbólico a
<scratch>.dB, y colocar en dB enlaces simbólicos con el mismo nombre que los archivos regulares de nivel superior del rootfs base,
apuntando a /run/udev/rules.d/97-cve-2026-8933-<pid>-<rand>.rules. En ese momento la resolución de rutas del bucle de copia
recae en dB — un directorio normal que controlamos por completo. El tmpfs se queda en dA y ya no
nos estorba.
Nota: cuando en .dA solo hay 1 montaje, es un bind mount ( /tmp ya es tmpfs, tras el bind el
fstype sigue siendo tmpfs); hay que esperar a que aparezca el segundo montaje (el tmpfs real) para redirigir, de lo contrario
el mount --make-unbindable de snap-confine fallará con EINVAL porque la ruta ya no es la raíz de un montaje.open(O_CREAT|O_TRUNC) (sigue el enlace simbólico y crea el archivo como root bajo /run/udev/rules.d),
y después fchown(fd,0,0). En ese instante el archivo todavía nos pertenece. El PoC usa
dos medios a la vez:
stat() vea el archivo
y el uid siga siendo el nuestro, hacer de inmediato chmod 0666 + escribir la regla udev (en ese momento snap-confine
está congelado y la ventana queda "abierta de par en par");chmod(). Cuando el spinner la gana,
el archivo es propiedad de root pero con modo 0666 — los bits de modo se conservan tras fchown(0,0),
así que la ruta principal también puede terminar de escribirlo (el código comprueba "si se puede escribir", no "si es
el propietario")./run/udev/** rw.
Montar/desmontar un sistema de archivos FUSE hace que el kernel emita un uevent add para el subsistema bdi,
y systemd-udevd ejecuta entonces como root el PROGRAM= de la regla.make
./poc --diagnose # 只读自检,不改动任何系统状态
./poc # exploit,默认最多 5 次尝试
./poc --attempts 8 # 增加尝试次数
./poc --file default256.png # 只用 base rootfs 里的这一个普通文件
sudo ./poc --cleanup # 清理(仅在异常中断留下残留时需要)
Parámetros:
| Parámetro | Descripción |
|---|---|
--snap NAME | Qué snap usar para el rootfs (por defecto firefox, también se puede usar la variable de entorno CVE8933_SNAP) |
--app NAME | La app de ese snap (por defecto se toma la primera bajo apps: en meta/snap.yaml) |
--file NAME | Usar solo este archivo regular del rootfs base; si no hay coincidencia, sale con error |
--attempts N | Número de intentos (por defecto 5) |
--step US / --step2 US | Tiempo de CPU por fragmento en cada una de las dos fases (microsegundos), para depuración |
--keep | Conservar los directorios <scratch>.dA / .dB y los enlaces simbólicos (para depuración) |
--diagnose | Autodiagnóstico de solo lectura |
--cleanup | Limpiar residuos históricos (requiere root) |
El registro se escribe en /tmp/cve8933.log, y la salida de depuración de snap-confine se escribe en
/tmp/cve8933-debug.txt.
CVE8933_PAYLOAD_CMD='<comando de shell>' permite reemplazar el cuerpo del payload (para demostrar la ejecución de otros
comandos como root); en ese caso no se limpia automáticamente; el comando personalizado todavía debe escribir /tmp/pwned, de lo contrario el PoC
considerará que esta vez no tuvo éxito.
$ ./poc
[+] WIN: uid=0(root) gid=0(root) groups=0(root)
[+] exploit succeeded, evidence in /tmp/pwned
/tmp/cve8933.log 中对应的一次成功过程(2026-10-23,改动后复测):