
Python PoC-эксплойт для CVE-2026-85706 — неаутентифицированное произвольное чтение файлов в GitLab CE/EE через обход кодирования пути в Workhorse, с описанием уязвимости и вариантами обхода.
Неаутентифицированное произвольное чтение локальных файлов в GitLab CE/EE. Затрагивает 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). CVSS 10.0, по сообщениям эксплуатируется в дикой природе. Оригинальный отчёт от s3ntago, а этот репозиторий — просто мой разбор + PoC.
Этот PoC опубликован исключительно в образовательных целях и для защитных исследований, чтобы помочь администраторам и исследователям понять и протестировать уязвимость. Запускайте его только против систем, которыми вы владеете или на тестирование которых у вас есть явное письменное разрешение. Если ваш экземпляр попадает в затронутый диапазон, прекратите чтение и сначала обновитесь до 19.1.8 / 19.2.6 / 19.3.2.
Три эндпоинта репозитория (POST :id/repository/commits, POST/PUT :id/repository/files/:file_path) находятся за requestBodyUploader в Workhorse. Обработчик Rails читает путь на диске напрямую из сырого поля запроса и вызывает до какой-либо аутентификации. не служит аутентификацией, потому что подписывающий round-tripper Workhorse прикрепляет валидный JWT к каждому проксируемому запросу, так что всё, что доходит до общего API-прокси, проходит эту проверку.
file.pathFile.openrequire_gitlab_workhorse!Gitlab-Workhorse-Api-RequestЕдинственная причина, по которой это не мгновенный LFI для всех, — Workhorse должен сначала переписать запрос. Но его regex маршрута сопоставляется с экранированным путём (EscapedPath() плюс клон с path.Clean, который никогда не декодирует %XX), тогда как Puma декодирует %XX до маршрутизации Grape. Поэтому процентно закодируйте любой символ статического сегмента (%63ommits, %72epository, %66iles), добавьте завершающий слэш или прицепите .json, который regex Workhorse пропускает, — Rails всё равно направит запрос в уязвимый обработчик. Это несоответствие кодирования и есть место обхода. (//, /./, %2F, ; и подобные варианты не работают, потому что path.Clean нормализует первые два, а Puma отвергает %2F.)
Затем просто отправьте подделанные неподписанные метаданные загрузки как query-параметры:
POST /api/v4/projects/1/repository/%63ommits?file=&file.path=<ABSOLUTE_PATH>&file.size=1&Content-Type=application/x-www-form-urlencoded
Пустой file= удовлетворяет валидации requires :file, WorkhorseFile (пустое значение приводится к nil). Чтение срабатывает до аутентификации. Получить байты обратно — самое интересное: в ветке urlencoded хелпер выполняет Rack::Utils.parse_nested_query(File.read(path)) и интерполирует ошибки парсера в тело ответа 400. Любой %, за которым не следуют две шестнадцатеричные цифры, вызывает InvalidParameterError: invalid %-encoding (<raw file bytes>) — содержимое файла возвращается внутри сообщения об ошибке. Ветка JSON (Oj) ничего не утекает, поэтому здесь важен именно urlencoded content type.
Исправление (master 0d9ce3e7, бэкпорты 1fe30154 / b43c8b26 / 0ff7b6b2) добавляет authenticate! ко всем трём эндпоинтам плюс предварительный шаг /authorize, доверяет только созданному middleware UploadedFile для пути/размера и перестаёт отражать ошибки парсера. Баг был внесён в декабре 2025, поэтому затронутый диапазон начинается с 18.7.
./exploit.py --url http://localhost:8080 --file /etc/hostname
./exploit.py --url http://localhost:8080 --file /opt/gitlab/embedded/service/gitlab-rails/config/gitlab.yml
Скрипт перебирает все проверенные формы обхода (закодированные сегменты, завершающий слэш, .json; эндпоинты commits + files, POST и PUT) и классифицирует каждый ответ, чтобы вы могли понять, где в цепочке умерла проверка. Только авторизованные цели, очевидно.
%, за которым не следуют два шестнадцатеричных символа.(.*) до последней ) в теле). Нормально против стандартной JSON-ошибки, но перехватит лишнее, если что-то перед ним обернёт ответ в HTML. Правильное решение — парсить поле сообщения JSON.before возвращает 404).