
Prova de conceito em C para a CVE-2026-8933, uma escalada de privilégios local no snap-confine que utiliza congelamento de cgroup, corridas de symlink e injeção de regras udev para executar comandos como root.
Reproduza o comunicado da Qualys "Local Privilege Escalation in set-capabilities versions of snap-confine" (https://cdn2.qualys.com/advisory/2026/07/21/snap-confine-set-capabilities.txt).
Testado na máquina local (Ubuntu 25.10 / snapd 2.73+ubuntu25.10.1 / kernel 6.17): partindo do uid 1000,
finalmente o systemd-udevd executa o comando como root, /tmp/pwned obtém
uid=0(root) gid=0(root) groups=0(root), consistente com o efeito demonstrado no final do aviso.
O registro das dificuldades encontradas durante a reprodução, e por que a primeira avaliação julgou erroneamente "não explorável", veja NOTES.md.
| Arquivo | Descrição |
|---|---|
poc.c | PoC completo (arquivo único) |
Makefile | Build (vincula o libfuse3.so.3.17.4 já instalado no sistema) |
include/ | Cabeçalhos do libfuse3 3.17.4 (correspondentes à biblioteca de runtime do sistema) |
NOTES.md | Registro das dificuldades + retrospectiva da metodologia de análise de alcançabilidade |
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)
Os números de linha foram extraídos do código-fonte do snapd 2.73 desta máquina (cmd/snap-confine/mount-support.c). O aviso da Qualys
cita sua própria cópia do arquivo, cujos números de linha correspondentes são 511 / 523 / 530 / 535 / 536 /
362 / 450 / 454 — as instruções correspondem uma a uma, apenas os números de linha diferem.
O /usr/lib/snapd/snap-confine desta máquina é mode=755 + file capabilities (não
setuid-root), portanto é executado com o euid do atacante, mas detém capacidades
quase de root: os diretórios e ficheiros que cria pertencem ao atacante durante
um breve período, e o open() na linha 418 segue links simbólicos.
A desmontagem confirma: nesta máquina, os flags de open@plt são 0x240 (O_CREAT|O_TRUNC, sem
O_NOFOLLOW); no mesmo ponto da versão 2.77.1 corrigida, são 0x20240.
systemd-run --user --scope --unit=snap.<snap>.<app>. Esse scope pertence ao usuário atual, cgroup.freeze é gravável,
portanto é possível liberar o snap-confine chamada de sistema por chamada de sistema.SNAPD_DEBUG=1, o snap-confine imprime
associated snap application process ... with device cgroup. Essa linha aparece após o attach do programa BPF
do cgroup de dispositivos e antes de mkdtemp(). Não se pode congelar antes: o
verificador BPF retorna EAGAIN quando há um freeze trap, e o snap-confine simplesmente encerra.
O stderr está conectado a um pty (não a um pipe): caso contrário, o stdio seria armazenado em buffer de bloco e o marcador
frequentemente só seria descarregado após mkdtemp() e o bind mount.
Além disso, o scope é iniciado com CPUQuota=1%: do marcador até mkdtemp() há apenas algumas centenas de microssegundos de
tempo de CPU, da mesma ordem de grandeza da latência de despertar do processo capturador; a limitação de taxa estende essa janela em tempo real
para a ordem de milissegundos, e, após confirmar o congelamento, cpu.max é imediatamente restaurado para 100%.mount(2) segue os symlinks no caminho
de destino, então o bind/unbindable/tmpfs do snap-confine cai todo em
<scratch>.dA, enquanto o laço de cópia continua usando a string do diretório scratch.ns_mount_count(sc, .dA) >= 2), redirecionar o symlink para
<scratch>.dB, e colocar em dB symlinks com os mesmos nomes dos arquivos regulares de nível superior do rootfs base,
apontando para /run/udev/rules.d/97-cve-2026-8933-<pid>-<rand>.rules. Nesse momento, a resolução de caminho do laço de cópia
recai em dB — um diretório comum que controlamos completamente. O tmpfs fica em dA e deixa de
nos atrapalhar.
Atenção: quando há apenas 1 montagem em .dA, trata-se de um bind mount (o /tmp em si é tmpfs; após o bind,
o fstype continua sendo tmpfs); é preciso esperar a segunda montagem (o tmpfs real) aparecer para redirecionar, caso contrário
o mount --make-unbindable do snap-confine falhará com EINVAL porque o caminho não é mais a raiz de uma montagem.open(O_CREAT|O_TRUNC) (seguindo o symlink, criando o arquivo como root em /run/udev/rules.d),
e em seguida fchown(fd,0,0). O arquivo ainda nos pertence nesse instante. O PoC usa simultaneamente
dois meios:
stat() vê o arquivo
e o uid ainda é o nosso, executar imediatamente chmod 0666 + escrever a regra udev (nesse momento o snap-confine
está congelado e a janela é "escancarada");chmod(). Quando o spinner vence,
o arquivo pertence a root, mas o modo é 0666 — os bits de modo permanecem após fchown(0,0), então o caminho principal
também consegue terminá-lo (o código verifica "é possível escrever", não "é o proprietário")./run/udev/** rw.
Montar/desmontar um sistema de arquivos FUSE faz o kernel emitir um uevent add para o subsistema bdi,
e o systemd-udevd executa imediatamente o PROGRAM= da regra como root.make
./poc --diagnose # 只读自检,不改动任何系统状态
./poc # exploit,默认最多 5 次尝试
./poc --attempts 8 # 增加尝试次数
./poc --file default256.png # 只用 base rootfs 里的这一个普通文件
sudo ./poc --cleanup # 清理(仅在异常中断留下残留时需要)
Parâmetros:
| Parâmetro | Descrição |
|---|---|
--snap NAME | Qual rootfs de snap usar (padrão firefox, também pode usar a variável de ambiente CVE8933_SNAP) |
--app NAME | O app desse snap (por padrão, o primeiro sob apps: em meta/snap.yaml) |
--file NAME | Usar apenas este arquivo comum do rootfs base; se não houver correspondência, encerra com erro |
--attempts N | Número de tentativas (padrão 5) |
--step US / --step2 US | Tempo de CPU por fatia em cada uma das duas fases (microssegundos), para depuração |
--keep | Manter os diretórios <scratch>.dA / .dB e os links simbólicos (para depuração) |
--diagnose | Autoverificação somente leitura |
--cleanup | Limpar resíduos históricos (requer root) |
Os logs são gravados em /tmp/cve8933.log, e a saída de depuração do snap-confine é gravada em
/tmp/cve8933-debug.txt.
CVE8933_PAYLOAD_CMD='<comando shell>' pode substituir o corpo do payload (para demonstrar a execução de outros
comandos root); nesse caso, a limpeza automática não ocorre; o comando personalizado ainda precisa gravar em /tmp/pwned, caso contrário o PoC
considerará que desta vez não houve sucesso.
$ ./poc
[+] WIN: uid=0(root) gid=0(root) groups=0(root)
[+] exploit succeeded, evidence in /tmp/pwned
/tmp/cve8933.log 中对应的一次成功过程(2026-10-23,改动后复测):