# Portainer Has an Arbitrary File Read via Git Symlink Injection in Stack Auto-Update

Git-backed stack with docker-compose.yml as a relative symlink makes Portainer's stack file endpoint read arbitrary host files as root.

- Payout: Research
- Severity: high
- Program: Portainer
- Found by: b-hermes
- Disclosed: 2026-05-14
- CVE: CVE-2026-44881
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-rpgq-m5fp-32wr
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-rpgq-m5fp-32wr
- Tags: area:cloud domain:infra domain:web impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:docker tech:go tech:kubernetes technique:chained technique:source-review vuln:info-disclosure vuln:path-traversal vuln:secrets-leak

**Target:** Git-backed stack deployment and GET /api/stacks/{id}/file endpoint
**Root cause:** go-git checkout converts Git mode 0o120000 blobs into real OS symlinks (only .gitmodules blocked), and GetFileContent uses os.ReadFile with lexical-only path containment, never calling filepath.EvalSymlinks.
**Steps:**
1. Create a Git repo whose docker-compose.yml is a symlink blob (mode 0o120000) with relative target like `../../etc/passwd`
2. Create a Git-backed stack in Portainer pointing at that repo; go-git clones and writes the symlink into the project dir under /data/compose/
3. Request `GET /api/stacks/{id}/file`; os.ReadFile follows the symlink and returns the target file contents
4. For deferred exploitation: start with a benign repo, then push a commit replacing docker-compose.yml with the symlink and wait for the auto-update cycle
**How found:** Reporter inspected go-git's Worktree.checkoutFileSymlink, noticed only .gitmodules is blocked from becoming an OS symlink, then traced the read path to GetFileContent and observed JoinPaths does lexical containment without EvalSymlinks.
**Exploitation:** Any authenticated user with stack-management rights (default in CE) supplies a repo URL; Portainer clones it, materializing the symlink, then the stack file endpoint returns the symlink target verbatim. Portainer typically runs as root, so /etc/shadow, /proc/self/environ, portainer.db, K8s SA tokens and /run/secrets are all readable, enabling onward compromise.
**Escalation:** Auto-update turns a reviewed-benign repo malicious post-creation with no interaction; leaked K8s SA token, registry creds and portainer.db (password hashes, API tokens) pivot to managed clusters and Portainer itself.
**Insight:** When a product clones attacker-controlled Git repos, check whether symlink blobs (mode 120000) are materialized on disk; then check whether the subsequent file-read path uses lexical containment only. Relative symlink targets bypass go-billy's chroot (which only affects absolute targets), so test `../../` targets.

